Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

大前端  前端知识宝库  坚持日更

关于这期分享内容

性能优化一直是前端领域老生常谈的问题,系统的性能以及稳定性很大程度上决定着产品的用户体验以及产品所能达到的高度。而 tob 和 toc 系统又有着不同的业务场景,性能优化也有着不用的着力点。本文从笔者的视角出发,结合自己针对一个 tob 系统的性能优化实践去剖析一些大家可能共同关注的点,争取可以以小见大。

关于团队定位

我所在的团队是一个涉及业务比较复杂的的教育前端团队,而谈及在线教育,始终绕不开在线讲义,在线课件这一关,我们所负责的业务旨在提供完善的在线课件解决方案:

我们输出的产品主要包括 编辑器渲染器 两部分。

  • 编辑器 除了提供基础的课件编辑制作能力外,还提供了组装各类教育资源的能力,这些教育资源包括互动题、cocos、pdf、ppt 等。

  • 渲染器 除了提供通用渲染器来支持基础课件的渲染以外,还支持接入各类教育资源的渲染器,来支持教育资源的渲染。

关于数据结构,大致数据结构如下所示,类似 ppt 的数据结构,每一页单页课件是一个 page,每页课件上中的文字图片音频视频都是一个节点,这些课件页以及节点都是以数组的形式来维护。

{    pages: [        data: {            nodes: [                'text', 'image', 'video', 'staticQuestion'...            ]        }    ]}

简单了解业务之后我们才能结合具体的场景讨论性能优化过程中遇到的问题。

性能优化历程

  1. 3-4 双月立项

我们的项目规划一般按照双月来制定目标,34 双月我们成立课件性能优化专项,双月目标是明显提升用户体验。

  1. 针对不同问题的解决方案

下面我会从遇到的具体 case 入手,来聊一聊我们是如何解决这些问题的。

  1. 课件列表页卡顿

  2. 原因分析

我们课件系统的数据依然采用了序列化数据存储(未分页),而我们打开编辑器时,会发请求拿到课件的所有内容,课件内容也会一股脑儿渲染在页面上,这样带来的结果就是页面的性能非常受课件体量的制约,随着课件内容越来越多,课件页面达到 100 页以上时,系统的性能就已经到达了瓶颈,具体表现为点击切换课件页卡顿以及列表页滚动卡顿。

我们在列表的 vue 组件的 updated 生命周期中添加了一个 log 查看组件渲染次数:

updated() {    // 查看该组件更新了多少次,勿删    console.log("%c left viewer rerender", 'color: red;');},

Vue 的 updated 官网这样解释道:

由于数据更改导致的虚拟 DOM 重新渲染和打补丁。当这个钩子被调用时,组件 DOM 已经更新,所以你现在可以执行依赖于 DOM 的操作。然而在大多数情况下,你应该避免在此期间更改状态。如果要相应状态改变,通常最好使用 计算属性 watcher 取而代之。

https://cn.vuejs.org/v2/api/#updated

于是我们发现点击整个单个课件页时,整个左侧列表都重新渲染,而每个课件页中的 log 也会执行,而且会渲染三次。

我们初步判断当点击单页时,组件执行了多余的 render,而在重新渲染之前虚拟 dom 的计算阻塞了单线程,导致 ui 假死。虽然 Vue 内部对虚拟 dom 的计算做了很多优化,但是在这个案例中我们看到,课件体量大时,单线程依然会阻塞,我们通过 performance 可以进一步证明我们的猜想。

通过 perfermance 可以看到,一次点击事件的处理时间达到 4.16s,这一桢的时间是 4500ms,在这四秒多时间内,浏览器是没有任何响应的,而通过观察我们发现这段时间耗时的操作就是 Vue 的虚拟 dom 计算过程,在 Bottom-up 中也可以看到,耗时操作 vue removeSub 移除依赖的操作,还有虚拟 dom patch node 的计算,这个过程是为了合并更新,这个计算堆积起来就非常耗时。

排查到这里我将原因归结为组件太多,不必要的更新太多,我们去查看了一下页面节点数量

200 页的课件全部渲染,页面节点已经到达了 3w 之多,而每次交互更新量巨大,浏览器重绘的压力也比较大(虽然这个时间比 js 计算还是少很多)。

经过以上的排查,我们总结原因为:Vue 的数据侦听应该更新变化了的 dom,但是我们点击某个课件页时,由于处理 Vuex 的数据流的方式不太合理,使得许多组件依赖了本不需要的数据,导致 Vue 判断组件需要重新渲染。其次我们的页面结构过于复杂,没有做动态渲染或者 feed 流类似的分片加载策略,浪费了很多资源。

  1. 解决方案

基于以上的原因分析,我们尝试了比较多的方案。其中由于 Vuex 数据流不合理带来的过多 rerender,由于项目过于复杂,涉及到了互动题编辑器和模版编辑器,数据流的改动风险较大,而且收益不一定明显。于是在 3-4 月的优化中,我们没有动现有的状态管理,而是在当前基础上,力求减少每次操作的计算量和渲染量,也就是在不合理的方案下去缓解用户体验问题。我们的思路聚焦在:课件列表需要动态加载,页面节点越少,挂载的组件越简单,Vue 的计算越快,浏览器渲染的速度也越快。 于是我们做了以下尝试:

  1. IntersectionObserver

借鉴图片懒加载的方式,我们通过浏览器的 IntersectionObserver 进行 dom 的监听实现课件页的懒加载

// 在需要懒加载的节点上添加ref属性为“containerNeedLazyLoad”// 将控制是否进行加载的boolean变量命名为“elementNeedLoad”export default {  data() {    return {      elementNeedLoad: false,      elementNeedVisible: false    };  },  mounted() {    const target = this.$refs.containerNeedLazyLoad;    const intersectionObserver = this.lazyLoadObserver(target);    intersectionObserver.observe(target);  },  methods: {    lazyLoadObserver(target) {      return new IntersectionObserver((entries) => {        entries.forEach((entry) => {          if (entry.intersectionRatio > 0) {            if (this.elementNeedLoad === false) {              this.elementNeedLoad = true;              this.$nextTick(() => {                this.elementNeedVisible = true;              });              this.lazyLoadObserver().unobserve(target);            }          } else {            this.elementNeedLoad = false;            this.elementNeedVisible = false;          }        });      }, {        threshold: [0, 0.5, 1],      });    },  }};

简言之就是我们给 200 页课件每一页的 dom 容器元素都添加了一个监听器,当该课件进入视窗时渲染内部的元素,在课件出视窗时注销元素,通过 v-if 指令来实现。该方案实现后,实时渲染的课件数量只有视窗内的 7-8 个,其他课件只渲染了容器组件,页面节点也少了很多,当我们点击切换课件时变得流畅了许多,那是因为未完全渲染的课件页 Vue 组件都变得「简单」了,vue 的计算也会更快,点击课件的时间可以在 300ms 内响应。极大优化了体验,于是我们满心欢喜上线了该优化。

  1. 动态加载

一天后,又有教研老师反馈页面卡顿,列表页经常滚动比之前更卡,于是我们再次排查。上面的方案留下了一个比较大的问题是,列表页在滚动的过程当中,需要实时监听 dom,我们不怀疑浏览器 api 的性能,但是 v-if 指令会在值变化时,执行虚拟 dom 的计算并更新组件,而这个过程是在滚动的过程中实时进行的。

从上面的视频中我们可以看到在滚动时很容易出现掉帧的情况,所以图片懒加载的方案无法直接嫁接,我们需要更好的懒加载方案。

在竞品调研过程中,我们比较关注 google doc 和腾讯文档的方案。

Google doc 采用了滚动懒加载的方式加载文档,但是由于 google 的底层方案都是基于 svg,方案无法复刻,但是动态加载的方式可以借鉴。

腾讯文档则是采用了分页加载,首屏渲染之后再加载其他的内容,但是由于我们在 url 上携带了课件 id 需要定位到具体的课件页,而且数据方面没有分页,因此该方案暂时不考虑。此外我们页关注了某教研云的课件加载方案:

与预想中的一样,某教研云也采用了动态加载的方式,可见这也算是长列表比较常见的优化手段。

于是我们有了以下优化思路:

  1. 默认每张课件不渲染任何节点,只渲染容器节点,dom 结构会简单很多

  2. 监听列表容器的滚动事件,计算当前视图最中间的课件 index,同时将上下各 7 张课件作为可渲染课件,添加滚动监听的防抖

  3. 添加可渲染课件 index 时通过 setTimeout 逐一添加实现流式加载

  4. 已经渲染的页面在下一次滚动中不再重复渲染

import { on, off, mapState, mapMutations } from 'utils';import debounce from 'lodash/debounce';/** * 总共加载当前课件的ppt上下若干页课件,其他的通过滚动延迟加载 */const renderPagesBuffer = 7;const renderPagesBoundary = 2 * renderPagesBuffer + 1;const debounceTime = 400;const progressiveTime = 150; // 渐进式渲染间隔时间/** * 持久化一下 */const bodyHeight = document.documentElement.clientHeight || document.body.clientHeight;export default {  data() {    return {      additionalPages: [],      commonPages: [] // 前后两次滚动所需要渲染的公共页面    };  },  mounted() {    this.observeTarget = this.$refs.pprOverviewList;    on(this.observeTarget, 'scroll', () => {      this.handleClearTimer();      this.handleListScroll();    });        if (!this.renderAllPages) {      this.updateCurrentPagesInView(new Array(renderPagesBoundary).fill(1).map((_, i) => i));    } else {      /* 先手动触发一次 */      const timer = setTimeout(() => {        this.handleClearTimer();        this.handleListScroll();        clearTimeout(timer);      }, debounceTime * 2);    }  },  beforeDestroy() {    off(this.observeTarget, 'scroll', this.handleListScroll);  },  computed: {    ...mapState('editor', ['currentPagesInView']),    pagesLength() {      return this.pptDetail?.pages?.length || 0;    },    renderAllPages() {      return this.pagesLength > renderPagesBoundary;    }  },  watch: {    additionalPages(val) {      this.observerIndex = 1;      this.handleRenderNextPage();    }  },  methods: {    ...mapMutations('editor', ['updateCurrentPagesInView']),    /**     * 增加滚动事件的防抖设置,防止频繁更新     */    handleListScroll: debounce(function() {      const { scrollTop, scrollHeight } = this.observeTarget;      const percent = (scrollTop + bodyHeight / 2) / scrollHeight;      // 找到当前滚动位置位于页面中心的 ppt      const currentMiddlePage = Math.floor(this.pagesLength * percent);      const start = Math.max(currentMiddlePage - renderPagesBuffer, 0);      const end = Math.min(currentMiddlePage + renderPagesBuffer, this.pagesLength + 1);      // 已经渲染了的页面集合(保证不重复渲染)      const commonPages = [];      // 滑动之后需要新渲染的页面集合      const additionalPages = [];      for (let i = start; i < end; i++) {        if (this.currentPagesInView.includes(i)) {          commonPages.push(i);        } else {          additionalPages.push(i);        }      }      this.commonPages = commonPages;      this.additionalPages = additionalPages;    }, debounceTime),    handleRenderNextPage() {      const nextPages = this.additionalPages.slice(0, this.observerIndex);      this.updateCurrentPagesInView(        [...nextPages, ...this.commonPages]      );      this.observerIndex++;      if (this.observerIndex >= this.additionalPages.length) {        this.handleClearTimer();      } else {        this.observerTimer = setTimeout(() => {          this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);        }, progressiveTime);      }    },    handleClearTimer() {      this.observerTimer && clearTimeout(this.observerTimer);      this.animationTimer && cancelAnimationFrame(this.animationTimer);    }  }};

其中需要注意的时,滚动的监听添加了 400ms 的防抖,我们一直滚动会感受到非常流畅,而在停止滚动开始渲染时,如果同时渲染计算得来的共 15 张课件,则在这些组件渲染完成之前页面依然是卡死的状态,因此我们采用了 setTimeout 实现渐进式渲染,宏任务的好处就是让我们可以在每一次事件循环中插入微任务,比如当前课件正在进行流式渲染,这时点击了某张课件可以先切换再继续渲染。

this.observerTimer = setTimeout(() => {

  this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);

}, progressiveTime);

另外当再次触发滚动事件时,需要清除此前所有的定时器重新计算,而已经渲染了的页面 index 我们存在 commonPages 数据中,在下一次计算时不进行清除。最终优化的效果如下:

可以看到从用户体验上,已经解决了滚动的卡顿问题,同时也不会因为组件过多阻塞用户的点击事件。

通过性能监控也能看到在滚动过程中几乎没有任何的计算,这也是滚动起来十分流畅的原因。

  1. 虚拟列表

我们前面也说到,这是在现有数据流不合理的情况下的无奈之举,而要彻底解决需要更完美的方案。由于 Vue 框架没有提供 React 的 memo 或者 shouldComponentUpdate 类似的钩子函数,让开发者决定组件是否应该重新渲染,因此在更彻底的解决方案中,由组内另一位同学主导设计,我们尝试用 react 虚拟列表进行重构。

长列表的终极优化方案还是要走向虚拟列表,无论是百度贴吧中期的技术重构,还是今日头条的 feed 流,都曾基于该方案做过探索。

虚拟列表是一种根据滚动容器元素的可视区域来渲染长列表数据中某一个部分数据的技术,具体在实现的时候,需要一个用于滚动监听的虚拟容器,和一个用作元素渲染的真实容器。

虚拟列表通过计算滚动视窗,每次只渲染部分元素,既减少了首屏压力,长时间加载也不会有更多的性能负担。虚拟列表中的元素都是绝对定位的,无论列表有多长,实际渲染的元素始终保持在可控范围内。

前端领域各个社区也有了比较成熟的解决方案,react-virtualized、react-window 以及 vue-virtualize-list 等等,关于虚拟列表原理的叙述可以参考以下文章,这里限于篇幅不再赘述:

https://github.com/dwqs/blog/issues/70

import { SortableContainer } from 'react-sortable-hoc';import { areEqual, VariableSizeList as List } from 'react-window';import React, {  useCallback,  useEffect,  useMemo,  useRef,  useState,} from 'react';export const ReactVirtualList: React.FC<any> = (props) => {  const list = useRef(null);  const initialScrolled = useRef(false);  const [dragging, setDragging] = useState(-1);  const { pages, currentPageId, selectedPagesId } = props;  const pageGroups = buildPageGroups(pages);  useEffect(() => {    props.onReady({ scrollToTargetPage });  }, []);  useEffect(() => {    list.current.resetAfterIndex(0);  }, [pages]);  useEffect(() => {    // 进入页面定位到选择页面    if (!initialScrolled.current && pages.length > 0) {      scrollToCurrentPage();      initialScrolled.current = true;    }  }, [pages]);  const scrollToCurrentPage = () => {    scrollToTargetPage(currentPageId, pages);  };    // 渲染列表的每一项  return <SomeThing /> }

同时由于最初我们缩略图的元素渲染器采用了跟用户操作区的同一个渲染组件,每个元素上都有很多事件监听,新版本我们也封装了基于 react 的纯 ui 元素渲染器 ,去掉了无用的事件监听,简化了 dom 结构。

两者结合就成了新版本的缩略图列表,目前已经上线完成,从各项指标以及用户体验来看,提升还是非常大的,其中更明显的拖拽排序,相较于此前的拖拽排序用户体验要好得多。

  1. 内存泄漏

同样是上述课件,经过上述的优化,页面的节点数量已经少了很多,而且卡顿问题也得到了改善。但是当我们不断滚动左侧预览图时,一段时间之后还是会卡顿甚至卡死。此时要么是 cpu 占用率过高导致页面无法响应,要么出现了内存泄漏问题,经过排查发现,虽然页面中的节点在懒加载过程中会注销,但是这些节点依然会被保存在内存当中,一直没有释放,甚至达到 10w 之多,内存占用也一直线性增长,我们需要针对这一些内存中的节点进行优化,处理内存泄漏问题。

我们通过内存快照可以看到,标记为 Detached 也就是脱离文档流的节点依然存储在内存当中,其中 DIVElement 有两万多个,展开发现都是 element-render (我们课件元素渲染器)中的元素,比如带有 render-wrap 或者 selection-zone 的这些类(都是我们项目中挂载在 dom 上的类名)。所以判断这个组件存在内存泄露问题。

排查的过程中,一度怀疑是 vue 的 v-if 造成的,在 vue 的官网有关于 v-if 内存泄漏的相关内容。

https://cn.vuejs.org/v2/cookbook/avoiding-memory-leaks.html

我尝试了通过手动挂载组件执行 $mount,在课件滑到视图之外时手动注销,依然没有作用,甚至尝试实现自定义指令 v-clean-node,在懒加载过程中动态注销节点。但事实证明,节点是已经从 dom 结构中注销了的,只是对应的 dom 片段依然保存在内存当中,而且笔者也没有找到可以手动释放内存的方法,至少在浏览器环境还没有办法办到。这里走了很多弯路,而思考的方向或许也能成为一些反面案例,不过性能优化这条路需要做的也是勇敢尝试,敢于试错,最终总能找到一个相对较优的解决办法。

我们换个思考的方向,既然不能手动释放内存,就去迎合 v8 内存管理的原理,代码怎样写才能保证内存被正常释放。我们想到的就是一直被引用的变量,其次就是未被注销的事件监听。

接下来的排查采用了打断点和注释代码的笨办法,逐渐缩小排查范围,最终锁定在了渲染富文本所使用的 render 组件。

这个组件用到了兄弟团队提供的 render 库,而在 node_modules 是编译后的 es5 代码,可读性还不错,于是我通过断点在在源码中进行调试,手动追踪调用栈。

在最终渲染的方法中,排查找到了这样一行代码:

这里每个富文本渲染都会添加一个 body resize 的事件监听,而笔者并没有找到相关 unobserve 的逻辑。类似这种事件监听的代码如果没有取消监听,很容易造成内存泄漏,注释这一行代码之后重启项目,系统的内存可以正常回收了,最终确定是由这个 sdk 导致了内存泄漏,后续兄弟团队的同学也协助解决了这一问题,重新发了一个正式包。

通过测试发现内存已经可以正常释放。

这里的内存泄漏可以用以下示意图概括:

未被释放的事件监听会导致对应的组件在卸载时并未被释放,因此我们的内存中会有这些 vue 组件。

日常项目开发的过程中,内存优化需要持续关注,我们课件编辑器的内存占用大概 100M 左右,需要在后续的开发过程中持续优化

  1. 点击预览按钮之后页面操作卡顿

这是一个非常有趣的案例,课件的预览是在当前页面打开一个弹窗嵌入预览页面的 iframe,每次点击预览之后回到编辑器总会出现或多或少的卡顿现象。

这是因为预览页和编辑器的域名相同,因此打开 iframe 时共享了同一进程。

iframe 作为升级版的 frame,一般来说都会被认为和上层的 parent 容器处在同一个进程中,他们会拥有父容器的一个子上下文 BrowserContext。在这种情况下,iframe 当中的 js 运行时便会阻塞外部的 js 运行,特别是当如果 iframe 中的代码质量不高而导致性能问题时,外层运行的容器会受到相当大的影响。这显然是我们不愿意看到的,因为 webview 中的内容仅仅会作为 IDE 拓展机制的一部分,我们不希望看到我们的外部 UI 和程序被 iframe 阻塞从而导致性能表现不佳。

iframe 线程

幸运的是,Chrom 在 67 版本之后默认开启了 Site Isolation。基于它的描述,如果 iframe 中的域名和当前父域名不同(也就是大家熟悉的跨域情况),那么这个 iframe 中的渲染内容就会被放在两个不同的渲染进程中。而我们只需要将 IDE 主应用的页面挂在域名 A 下,而同时将 iframe 的的页面挂在域名 B 下,那么这个 iframe 的进程就和主进程分开了。在这种模型下,iframe 和主进程仅仅能通过 postMessage 和 message 事件进行数据通讯。但是在上面的模型中,仍然有一点需要注意。基于 Site Isolation 的特性,同一个页面中如果有多个,拥有同一个域名的多个 iframe 之间是共享进程的,因此他们仍然会互相影响性能。如果某个业务场景需要一个更为独立的 iframe 进程,它必须和其他 iframe 拥有不同的域名。

我们在项目中分别嵌入了百度首页和我们课件渲染页面,发现一级域名相同时 iframe 和当前页面总是会共享进程 id,无论嵌入页面的性能如何,对当前页面都会有或多或少的影响。因此我们有了以下解决方案:

  1. 预览页面部署到新的域名上,两者不共享进程

  2. 通过 a 标签打开新的页面进行预览,需要注意的是 a 标签需要加上 rel="noopener" 属性,切断进程联系

<a  v-if="showOpenEntry"  class="intro-step3 preview-wrap"  rel="noopener"  target="__blank"  :disabled="!showEditArea"  :style="{ color: !showEditArea ? 'lightgray' : '#515a6e' }"  :href="pageShareUrl">  <lego-icon type="preview" size="16" /></a>

目前的优化中采用了第二种跳转的方式作为临时方案。

  1. 添加动画时间过长

有这样一个场景是老师需要给多个元素同时添加动画,但是页面需要几秒钟响应,对于用户来说就是出现了卡顿。

我们依然通过 performance 排查,确定了动画表单的渲染阻塞了 ui 的更新,同样涉及到长列表,但此处没有课件列表复杂,而且课件页都渲染了一个固定高度的容器,此处每个动画表单高度都不一样,因此我们采用另一种懒加载的渲染方式:

export default {  data() {    return {      // 当前渲染动画数量      nextRenderQuantity: 0    };  },    computed: {    animationLength() {      return this.animationConfigsUnderActiveTab.length;    },    renderAnimationList() {      // 从原数组中切割      return this.animationConfigsUnderActiveTab.slice(0, this.nextRenderQuantity);    }  },  watch: {    animationLength: {      handler() {        this.handleRenderProgressive();      },      immediate: true,    }  },  beforeDestroy() {    this.timer && cancelAnimationFrame(this.timer);  },    methods: {    /**     * 动画表单的渐进式渲染,每一帧多渲染一个     */    handleRenderProgressive() {      this.timer && cancelAnimationFrame(this.timer);      if (this.nextRenderQuantity < this.animationConfigsUnderActiveTab.length) {        this.nextRenderQuantity += 1;        this.timer = requestAnimationFrame(this.handleRenderProgressive);      }    },  }};

通过 requestAnimationFrame 每一帧添加一个动画,也就是 40 个元素同时添加动画,需要 40x16 = 640ms 渲染完表单,而在这个时间之前,页面已经及时作出了响应,老师在使用的时候就不会觉得卡顿了。

优化前后的响应时间从 2.35s => 370ms,优化效果比较显著。

总结:不要让你的 js 逻辑阻塞了 ui 的渲染。

  1. 其他的优化

其他的一些常见的项目优化就不在此赘述了,无论什么样的业务场景,tob 还是 toc 系统,大家可能都曾在以下优化方向上摸爬滚打过。

  1. 路由懒加载

  2. 静态资源缓存

  3. 打包体积优化

  4. 较大第三方库借助 cdn

  5. 编码优化:长数组遍历善用 for 循环等

  6. 可能的预编译优化

深入框架,寻找性能优化方向

Vue 的懒人哲学 vs React 暴力美学

在性能优化的路上越走越偏,我也深深感受到前端工具带来的便利和过分依赖前端框架所带来的所谓的 side effect。vue 和 react 作为如今最火的两个框架,各自有着其独特的魅力,而性能优化的同时我们始终绕不开框架的底层原理。

Vue 的懒人哲学:

曾经的一次分享中我们提到了 vue 所谓的懒人哲学 ,也就是说 vue 框架内部为你做了太多优化工作。

我们知道 Vue 会对组件中需要追踪的状态,将其转化为 getter 和 setter 进行数据代理,构建视图和数据层的依赖,这就是 ViewModel 这一层。而正是由于 vue 精确到原子级别的数据侦听使得其对数据十分敏感,任何数据的改变,vue 都能知道这个数据所绑定的视图,在下一次 dom diff 时,他能精确知道哪些 dom 该渲染,哪些保持不动。而 vue 的这个原理也是他升级 Vue3 时进行更高效的预编译优化的前提条件,感兴趣的同学可以跟我探讨下曾经的分享,这其中也聊到了 Vue。

https://zhuanlan.zhihu.com/p/158880026

但是最大问题在于,vue 更新视图恰好不多不少的前提是,你的数据流十分干净,没有多余的数据更新,否则「敏感」的 vue 会以为更多的组件需要重新渲染,这也是目前我们课件编辑器的问题所在,项目体量越来越大,几乎没有几个开发者可以保证自己所维护的状态管理干净透明,而一旦有不合理的数据更新,组件的重新渲染是无法从中拦截的,因此用 Vue 可以让你「懒」一点,也需要你写代码时「小心」一点。而对比 react,两者底层设计的不同导致在遇到此类问题时,我们可能需要不一样的思考方向。

现在在知乎上还能翻到一些尤大关于 react 性能问题的理解。

尤大说的比较通俗易懂,而且也确实直指 react 框架的要害,其中所提到的 react 把大量优化责任丢给开发者,相信大家都有所感受。

React 的暴力美学:

与 Vue 不同的是,React 对数据天然不敏感,框架不关心你更新了多少数据,乃至更新了多少脏数据,数据与 dom 结构也没有 vue 那种依赖关系,你只需要通过 setState 告诉我我应该渲染哪些组件即可。与 Vue 相比,react 的处理方式既优雅又暴力,而且从开发者的角度来审视,react 的这种设计真的减少了太多的心理负担,而作为初接入 react 的开发者来说,你不会因为多更新了数据导致过多的 rerender 而抓耳挠腮,你要做的就是借助框架本身去消除这些副作用,众所周知 react 正好提供了这些能力:

  • Reat.memo

  • PureComponent

  • shouldComponentUpdate

你需要的,都能借助框架或者第三方工具做到。

谈到这里,不妨具体说说框架如何避免不必要的渲染问题:

以 react context 为例,如果我们在项目中所有的状态管理都放在一个 context 中,那么在使用时总会引起不必要的渲染。而在开发过程中如何避免,不同开发者都有不同的心得。

const AppContext = React.createContext(null);const App = () => {  const [count, setCount] = useState(0); // 经常变的  const [name, setName] = useState('Mike'); // 不经常变的  return (    <AppContext.Provider value={{      count,      setCount,      name,      setName    }}>      <ComponentA />      <ComponentB />    </AppContext.Provider>  )}const ComponentA = () => {  console.log('这是组件A');  const context = useContext(Context);  return (    <div>{context.name}</div>  )}const ComponentB = () => {  console.log('这是组件B')  const context = useContext(Context);  return (    <div>      {context.count}      <button        onClick={() => {          context.setCount((c) => c + 1)        }}      >        SetCount      </button>    </div>  )}

在这个 demo 中,我们在顶层注入了 Context 做状态管理,同时有一个经常改变的状态 count 和一个不经常改变的状态 name,组件 A 和组件 B 分别引用了 name 和 count 这两个状态。

我们在点击 SetCount 时,调用了全局上下文 中的方法,同时观测到 A B 两个组件都会重新渲染,而实际上我们的组件 A 只用到了 name 这个状态,是不应该重新渲染的。这里的数据流其实非常「干净」,没有多余的引用,如果是 Vue,它会追踪依赖而避免组件 A 的渲染,react 却没有做到。而作为 react 开发者,如果放任这种不必要的 rerender 不管,那正如尤大所说, react 应用的性能确实会遇到瓶颈,好在 react 给了开发者足够的发挥空间,大多开发者遇到此类场景,反手就是一个 context 拆分优雅解决:

const AppContext = React.createContext(null);const AppContext2 = React.createContext(null);const App = () => {  const [count, setCount] = useState(0);  const [name, setName] = useState('Mike');  return (    <AppContext.Provider value={{      name,      setName    }}>      <AppContext2.Provider value={{        count,        setCount      }}>        <ComponentA />        <ComponentB />      </AppContext2.Provider>    </AppContext.Provider>  )}const ComponentA = () => {  console.log('这是组件A');  const context = useContext(Context);  return (    <div>{context.name}</div>  )}const ComponentB = () => {  console.log('这是组件B')  const context = useContext(Context);  return (    <div>      {context.count}      <button        onClick={() => {          context.setCount((c) => c + 1)        }}      >        SetCount      </button>    </div>  )}

这里我们将两个状态拆分进不同的 context 中,此时再调用 setCount 方法,就不会影响到组件 A 重新渲染了。这也是我们实际项目开发中最常见的解决方案。但是项目体量越来越大时,这种模块的拆分会变得很繁琐,类似 Vuex 模块的拆分一样,我们开发一个新功能,也总是不愿意在 vuex 中新开辟一个模块,写更多的文件以及 getters mutations。所以在这个例子中我们也可以通过 useMemo 来解决:

const ComponentA = () => {  console.log('这是组件A');  const context = useContext(Context);  const memoResult = useMemo(    () => {      <div>{context.name}</div>    },    [context.name]  )  return memoResult;}

我们在组件 A 中,组件内容用 useMemo 包裹,将其制造为一个缓存对象,这里的 useMemo 不去缓存 state,因为我们调用了顶层方法 setCount 引起 state immutable 更新 -> 进而 name 更新(引用地址变化),在顶层组件中缓存 state 其实并没有什么用,所以在这个案例中 useMemo 只能用来缓存组件。

当然,我们不能每个组件都通过 useMemo 来处理,很多时候只是平添开销。因此 react 团队所提出的 context selectors 才是解决类似案例的最佳选择:

https://github.com/reactjs/rfcs/pull/119

通过 selector 机制,开发者在使用 Contexts 时,可以选择需要相应的 Context data,从而规避掉「不关心」的 Context data 变化所触发的渲染。这跟我们手动拆分 context 所实现的功能如出一辙,总的来说优化思路还是比较一致的。

react 更多案例可以参考:

https://codesandbox.io/s/react-codesandbox-forked-s9x6e?file=/src/Demo1/index.js

聊到这里我们发现,当使用框架作为开发工具来解决问题时,如果产生了副作用,react 开发者有很多方式可以抵消这个副作用,而相对来说 vue 以及 vue 生态圈所能提供的解决方案就比较少,正如前面我们遇到的那些 bad case 一样,我们可能需要从一些比较偏的角度去思考才能解决这类问题。

题外话(个人观点):

个人认为 Vue 做大型项目有着天然的弊端,由于递归实现了精确数据侦听,使得其产生了过多的订阅对象,而正如前面所说,一旦数据流不合理,多余的更新不可逆,而过多的侦听对象对系统内存也是一个考验。令一点就是笔者的自我感受,Vue 项目开发在组件化不如 React 来得清澈透明,单文件组件大了之后,可读性也比较差,而 react 有社区加持,有 Redux 和 Saga 进行状态管理,上手曲线虽然略高,但是代码规范度极高,状态管理效果极好,适合团队开发。反观 vue, 做小型项目却有着天然优势(为什么?),因此每个项目在前期都要着重分析业务场景,做好项目规划和技术选型。个人观点,希望各位同学指出不足,理性讨论。

以上是笔者在我们课件编辑器的项目中一些优化实践,不同场景有不同的解决方案,希望大家也可以留言给到一些建议和帮助,让我们课件团队可以打磨出更好的产品。

关于性能优化的建议

  1. 面向用户,了解用户真正的痛点

缩小产研团队和用户之间对产品理解的 gap。

  1. 发现问题,问题就解决了一半

发现问题,也需要发现问题的根源,性能问题的背后,往往是编码的不合理以及工具的不合理应用。

  1. 归纳总结,触类旁通。

相似的问题千篇一律,有趣的方案各有各的特色,每次性能优化之后,归纳总结总能带来更多的收获。

1. JavaScript 重温系列(22 篇全)

2. ECMAScript 重温系列(10 篇全)

3. JavaScript 设计模式 重温系列(9 篇全)

4. 正则 / 框架 / 算法等 重温系列(16 篇全)

5. Webpack4 入门(上)|| Webpack4 入门(下)

6. MobX 入门(上) ||  MobX 入门(下)

  1. 120+ 篇原创系列汇总

回复 “加群” 与大佬们一起交流学习~

点击 “阅读原文” 查看 120+ 篇原创文章