前言
📢 博客首发 : 阿宽的博客
✋ 本文主要是记录一次使用 hooks+echarts 实践的过程
这篇文章屯在草稿箱好久了,今天把它拎出来,给大家看一下翻车现场~
看完这篇文章你能学到什么
我哪知道你能学到什么,可能你啥都学不到,也可能看完之后,心血来潮,评论区写下 666,开个玩笑,这篇文章其实是我用 hooks 搭配 echarts 实践的一次过程,本以为真香,没想到拉肚子了~ 个人感觉精华部分在性能优化那里
本文适合人群
- 吃瓜群众
- 520 没有对象的看官
- hooks 初学者
- ...
正文开始
某次逛知乎时,看到两个段子,我觉得挺好笑的,如 👇
开发只有一个 PY,但所有产品都想插进来。
产品经理失踪了,程序员去报警,警察对程序员说 : 你先冷静一下,你这样一直笑没办法做笔录。
从中可以看出来,程序员和产品经理水火不容,但是呢,我觉得我的产品经理人挺好的。为啥,你看这个需求,🉑还行 ?
引用产品经理话 : “做一个图谱,后台给你数据,你用第三方库 Echarts 进行展示,就好了”
嗯,真贴心,都告诉我用Echarts了,小彭琢磨着,好像确实没那么难哦~ 赶紧开搞,搞完去按个摩~
本身对于需求来讲,确实不难以实现,基于 Echarts 进行开发,只要我们根据文档说明,将数据传入,再通过配置 options,就能得到一个漂亮,符合视觉要求的图表。
那我本身遇到了什么问题呢?在说问题之前,我先给你们大概说一下需求吧
- 点击时间段,切换时间,重新获取后台数据,进行展示
- 点击全屏按钮,切换至全屏,同时更变主题色调,从
Light变为Dark - 点击某个点,弹窗显示,点击其它点 || 点击空白区域,弹窗关闭
给大家看个最终效果图,(该图片经过内部同意,允许发出~)
问题列表
全屏问题
先说说全屏这个问题吧,视觉要求,非全屏 => Light,全屏 => Dark,于是小彭洋洋洒洒写下了这段代码
const [theme, setTheme] = useState(THEME.LIGHT); // 默认Light主题
// 进入全屏
function openFullScreen() {
setTheme(THEME.DARK);
}
// 退出全屏
function closeFullScreen() {
setTheme(THEME.LIGHT);
}
老铁,没毛病,666,按照正常逻辑,如果你点击的是切屏的按钮,那确实不会有问题,但是!!!我们调用的是浏览器的全屏,当我们按下 ESC 退出键之后,直接就退出了。尼玛,为啥啊?
小彭懵了呀,咋跟女朋友一样,这不按套路出牌,玩 NM 啊,于是去咨询了一下阿宽,哦豁,明白了,原来如此...
首先我们调用的是浏览器的全屏,通过 requestFullScreen 进行全屏/非全屏的切换,而 ESC 是 requestFullScreen 自动封装的,这里问大家一个问题,“ 那我监听 keyCode = 27,在按下 esc 键之后,触发对应事件,行不行?”
神州行,我看行!小彭去试了一下,哎呀,还真不行,前边说了啊,这个 ESC 是这个玩意自动封装了的,用 JS 方法监听无效,那怎么办 ?不慌,虽然我们知道,在使用 esc 离开全屏幕时不会触发按键事件。 但是,fullscreenchange事件是会被触发的。所以最终代码如何写呢?
我们先把 requestFullScreent 抽成一个通用的方法,避免下次产品又丧心病狂要实现 XXX 全屏
此处代码过多,省略......👇(具体代码可在 github 查看)
import { fullScreen, cancelFullScreen, isFullScreen } from '@common/fullscreen';
function TuPu() {
const domRef = useRef(null);
const [theme, setTheme] = useState(THEME.LIGHT); // 默认Light主题
function openFullScreen() {
setTheme(THEME.DARK);
fullScreen(domRef.current); // 浏览器进入全屏
}
function closeFullScreen() {
setTheme(THEME.LIGHT);
cancelFullScreen(); // 退出浏览器全屏
}
// 这才是重点 ❗❗❗
// fullscreenchange 一定会被触发,监听这个,盘它!
useEffect(() => {
function exitHandler() {
if (!isFullScreen()) {
// 当前处于全屏下
cancelFullScreen();
setTheme(THEME.LIGHT);
}
}
document.addEventListener('webkitfullscreenchange', exitHandler, false);
document.addEventListener('mozfullscreenchange', exitHandler, false);
document.addEventListener('fullscreenchange', exitHandler, false);
document.addEventListener('MSFullscreenChange', exitHandler, false);
return () => {
document.removeEventListener('webkitfullscreenchange', exitHandler, false);
document.removeEventListener('mozfullscreenchange', exitHandler, false);
document.removeEventListener('fullscreenchange', exitHandler, false);
document.removeEventListener('MSFullscreenChange', exitHandler, false);
};
});
return (
<div ref={domRef}>
<EchartsCanvas />
</div>
);
}
性能问题
💥 应该说是本文的精华了,因为我大部分时间都在处理这个性能问题。
不吹不黑,我一开始做的时候,组件结构一坨,堆在一起,没有一个明显的层级结构,其次是没做什么优化,导致不断的re-render,你能理解,几百上千个点,几千条线,Echarts 还没画完,又 re-render,页面卡顿,小彭内心崩溃的 💔 ?
科普一下,何时会进行 re-render ?
- 父组件重新渲染
- 组件内修改 state 的值,依次根据 react 生命周期更新(shouldComponentUpdate -> componentWillUpdate -> render -> componentDidUpdate)
- 组件的 props 更新,依次走生命周期 componentWillReceivePorps -> shouldComponentUpdate -> componentWillUpdate -> render -> componentDidUpdate
回到我们需求,我们看看依赖的 state 和 props 有哪些 ?
-
state
- theme 主题
- timeRange 时间段
- isAllData 全部知识点
- isFullScreenState 全屏
-
props
- id
- aName
- aCode
- bName
- bCode
那么如何去做优化呢 ?
避免无效的请求
我们用 useEffect 大部分时候都是用来发起请求的,对于它如何使用我就不说了,但是呢,一定要注意,避免无效的请求!!!因为有时候用着用着,你会发现,你莫名其妙多发了 n (n=1,2,3..) 次请求,比如 👇
😁 根据id与aCode,发送请求
// 下边写法是有问题的 ❌
useEffect(() => {
this.fetchData({
uid: props.id,
code: props.aCode
});
this.storeAName(props.aName);
}, [props]);
有啥问题,我们来看哈,你想在 useEffect 中做什么?无非就是发送请求,请求依赖参数有哪些?id 和 aCode,那么第一种写法有什么问题~ 首先依赖的是整个 props,那么如果 props 发生改变,比如 props.otherValue 改变,请求会不会执行?答案是: 会,但此时的请求是我们想要的吗?显而易见,不是...
小彭一听,有道理,果然还是四只眼的阿宽懂得多,于是它改成了这种方式
// 这个写法是有问题的 ❌
useEffect(() => {
this.fetchData({
uid: props.id,
code: props.aCode
});
this.storeAName(props.aName);
}, [props.id, props.aName, props.aCode]);
有问题吗?有,我们想一下,会不会存在这种情况,props 数据流传递的数据,会不会出现 👇
props.id -> props.aName -> props.aCode -> ...
就是 id 先传递,然后再传递 aName,最后是 aCode?也就是说,如果我们在里边打印一个 console.log,那么控制台会输出三遍
快,数学题来了,console.log 会打印三遍,请问,请求发送几遍?
其次,我们再来看,这个 props.aName 是不是应该放在这个 useEffect 中,在官网中,我们可以看到,它倡导我们: 使用多个 Effect 实现关注点分离,也就是说,我们每一个所依赖数组的 useEffect 都只关注于做一件事,就跟我一样,一心一意 ~
所以我认为正确的写法是这样的 ✅
useEffect(() => {
if (props.id && props.aCode) {
this.fetchData({
uid: props.id,
code: props.aCode
});
}
}, [props.id, props.aCode]);
useEffect(() => {
if (props.aName) {
this.storeAName(props.aName);
}
}, [props.aName]);
你依赖哪些值,就在第二个参数中添加就好了,不要直接写 props,为啥?如果你的 props 还有其它值,这时,该值的改变,会使得你这个请求又一次触发(无用请求)~
useMemo 发挥作用
我们知道,在 React Class Component 中,可以通过 React.PureComponent 进行不必要的 re-render,再不济你手动在 shouldComponentUpdate里边决定是否需要 re-render,在 hooks 官网中,有一个 FAQ,它有说到,该如何实现 shouldComponentUpdate ,解决方案就是 React.memo?
啥啊,不是要说 useMemo ,怎么变成 React.memo 了。不慌不慌,我们慢慢看~
const aKuanMemo = React.memo(props => {
return <div>阿宽666</div>;
});
React.memo 等效于 PureComponent,但它只比较 props。(你也可以通过第二个参数指定一个自定义的比较函数来比较新旧 props。如果函数返回 true,就会跳过更新。)
React.memo 不比较 state,因为没有单一的 state 对象可供比较。但你也可以让子节点变为纯组件,或者用 useMemo 优化每一个具体的子节点。
再来看看组件的设计,这次我对组件做了一次划分。因为该图谱在其它端也需要展示,需要做成通用,于是我的组件设计为 :
主要思想为 : 封装的图谱组件,是一个纯展示组件,不做任何复杂逻辑,不做任何修改 state、props 的的操作。
所有的操作都在外部业务组件进行,只需要你给我传数据,然后进行展示就好了~
同时,细心的小伙伴应该看到了一个词 : useMemo,你看,这不就是我上边说的吗~
那么这是个什么玩意呢?可以这么理解,useMemo Hook 允许你通过「记住」上一次计算结果的方式在多次渲染的之间缓存计算结果,举个例子
cosnt parentMemoComponent = useMemo(
() => (
<ChildComponent
theme={theme}
isFullScreen={isFullScreenState}
// ......
/>
),
[id, aCode, theme, isFullScreenState, ...]
)
这段代码中,如果依赖数组 [id, aCode, theme, isFullScreenState, ...]自上次赋值以来没有改变过,useMemo 会跳过二次调用,只是简单复用它上一次返回的值。
所以我们可以将 useMemo 作为一种性能优化的手段,在 ParentMemo 组件中,即使我们的 state 、props 发生了改变,但是只要依赖数组不变,在 re-render 时,子组件 ChildMemo 不会重新渲染(因为 parentMemo 是上一次的值)
自然而然的,ChildMemo 不改变,不会引起 re-render 重渲染,即使子组件发生了 re-render,但是我们在 ChildMemo 中,也同样用 useMemo 进行了优化。在一定程度上, 还是有一点点用处的~ 最起码不会多次的 re-render 了!!!
Performance 分析
不是我吹嘘,我一开始还以为是数据问题,怀疑是不是因为数据量太大,导致卡顿;马克思说过: 实践是检验真理的唯一标准,为此,我把官方 demo 拷贝了下来,把后台数据经过 JSON 然后塞进去,跑了一下,发现,demo 例子渲染的贼快,那么为什么我的那么卡顿 ?
首先,我先去 Echarts issues 看,发现,原来不止我一个人遇到这个问题,比如这个 issue: Echarts 3.1.7 Graph 图 在大量数据下响应迟钝,这不就是我这个问题吗,我兴致勃勃点进去,虽然没发现解决方案,但是我看到一个有意思的知识点,兄弟们,要学会抓住关键词 : Chrome Profile、matrix transform
我在 issues 里遨游了一圈,又看到了一个有趣的 issue: echarts 实例无法完全 dispose,造成内存溢出,oh ~ 上帝,这可真是一个有意思的东西呢,划重点 : 内存溢出
我懵了,这啥情况,我就想,看一下为啥卡顿,怎么就蹦出几个看起来就高大上的词,于是我又进一步替兄弟们踩坑,进一步排查,首先我们先来看看,上边说的 Chrome Profile 和 matrix transform 是个啥玩意吧~
首先打开控制台,然后...进入到 Performance,我们可以看到
卧槽,花费了 40s,可以,用户不拿刀砍我,我都谢天谢地了,我们任意挑一秒钟看一看一个具体的 Task,发现都是 Animation Frame Fired 和 Function Call 搞的鬼
总共就 130s,JS 你占了 128s,点击 Bottom-Up,进去看一看到底是什么妖魔鬼怪
可以,这个 Animation.js 文件就很不懂事,它不懂事,我不能不懂事,于是我很懂事的点进去看了一下,没啥大问题,是我想象中的那样
_startForceLayoutIteration: function(forceLayout, layoutAnimation) {
var self = this;
(function step() {
forceLayout.step(function(stopped) {
selft.updateLayout(self._modol)
(self._layouting = !stopped) && (layoutAnimation ? self._layoutTimeout = setTimeout(step, 16) : step());
})
})
}
在上边代码,每隔 16ms,都会将 step 函数推向 Task 中,一般我们想要达到平滑效果,那得在 1s 中内,执行 60 次,然而,每一帧,JS 执行都要 142ms,而 setTimeout 在 16ms 就会 push 到 Task,但是此刻,js 还在执行,js 是个单线程的玩意,所以出现阻塞,并不是浏览器渲染不过来,而是 js 执行不过来。
假设,按照每次都是 142ms,那么 1s 内,可能我们才能执行六七帧,是不是意味着,我们的帧率最大值也就六或者七?于是去开发者工具里边,查看了一下帧率 FPS 有多少,果然,预言家小彭~
结论为: 最慢的时候,在 1.1 FPS,最快的时候是 6.3 FPS,而一般都在 3FPS ~ 5FPS。呵,he tui ~
于是我又去看了一下,为什么会内存泄漏,这边我看到的一个比较有效的说法是 : 在我们页面切换后,echarts 图例是销毁了,但是这个 echarts 的实例还在内存当中,同时它的气泡渲染定时器还在运行。这就导致 Echarts 占用 CPU 高,导致浏览器卡顿,当数据量比较大时甚至浏览器崩溃。
echarts 本身绘制节点不耗时,当节点多的时候,实际上主要耗时的实在 matrix transform 里,不仅要对图形的 transform 计算,还有包括大量占用堆内存造成的 GC 开销,导致渲染过程一般要好几秒,因为大量占用堆内存,导致垃圾收集器 GC 在分配内存以及释放内存上面,占用更多的 CPU 时间,在 10000 个点内,setOptions 确实只要 1s 左右,但是渲染页面花了好几秒,是因为上诉所说的原因,卡顿原因是由于 echarts 实例在内存中,使得 echarts 占用 CPU 高,且内存不断增加,故而应该释放 echatrs 实例。(小声逼逼,其实我释放实例,感觉也就流畅了一丢丢)
结尾
虽然说最终效果很重要,但我认为更加重要的是,在问题中,能够静下心的去分析,从代码层面,组件设计层面,包括借助 Performance 等一步一步分析,我感觉这才是有价值的,这才是有所进步的。不得不说,后边确实快了很多,一部分是做了优化,一部分是导师做了 webpack 方面的优化(也有因为项目太大,导致卡顿)
最后
可能大家因为我上一篇文章: 前端渣渣唠嗑一下前端中的设计模式(真实场景例子) 的文笔,才关注了我,以为我文笔很好,其实也就这样,但我会尽我所能,产出能提供一丝丝帮助的文章,peace & love,好了,今天 520,大家单身快乐 🌹 没对象的来评论区,我帮你们 new 一个~