前端性能调优心法

前言 性能问题是软件开发中的常见问题,我们在几乎每个项目在某个时期(往往是在后期快要交付的时候,或者已经上线以后收到用户反馈)都或多或少会遇到。这篇文章想要从流程方面和具体的技术细节上对软件性能优化上遇到的问题做一些总结和分类,以方便在后续类似的场景下可以提供给开发者一个参考。 严格意义上,这篇文章并没有太多的新内容,甚至有一些具体的技术细节我在另一篇文章中已经讨论过,这里主要还是提供一些常见的关于性能优化思路的总结。 在修改之前 性能优化之法,曰立,曰测,曰理,曰拆,曰分,曰剥,曰拖,曰缓。 我们讨论性能提升,往往需要首先建立一套测量机制。因为仅凭直觉来猜测可能的性能瓶颈非常低效,而且往往直觉认为有性能问题的地方未必真有问题。一旦测量机制建立,则犹如我们代码有了单元测试/集成测试的守护,总的来说会向着正确的方向演进。 立字诀 重中之重的是,定义好指标。即DoD(Definition of Done),我们需要回答的问题是:什么是好的性能?达到何种标准就算是提升,而达不到就算是失败?这一点从项目的确立角度非常关键。 如果说希望某个页面的性能较之以前来说,加载时间提高20%为成功,则一切的后续开发可以做到有的放矢,而不至于无疾而终。 测字诀 一旦我们定义好了何为提升。接下来就需要建立相应的测量机制,并设置基线。这一步相当于将上一步定义好的标准实例化到build pipeline中,使得具体目标可视化起来,从而每次的修改都能看到和目标的差距。 比如从请求发出到页面渲染完成(比如检测到某个标的在页面上的存在与否),总共耗时3秒,然后我们将3秒设置为基线,并围绕这个基线设置测试的上限。和其他测试一样,如果后续的代码修改使得页面渲染时间大于基线值,则build失败。与之对应的还可以有诸如bundle的尺寸(压缩后的静态资源大小)首次渲染时间等等指标。 有了具体的目标,我们就可以设置相应的测试机制。比如通过运行yslow或者其他lighthouse来进行。 理字诀 当我们定义了性能优化成功的含义,也有了相应的反馈机制,如何做才会成为最重要的主题。对于这个问题,常用的工具就是分析和分类。 首先需要的分析“慢”的类型,是纯性能问题,还是架构问题,或者是软件设计上的问题。纯性能的问题往往较为具体,也最容易解决,比如使用了性能较低的包作为依赖,则只需要替换为性能更好的库即可;又或者使用debounce/throttle来减少对函数的频繁调用等等。 与纯粹的性能问题相对应的另一大类问题,都可以归结到设计问题(大到软件架构,小到模块间的耦合/依赖等问题)。这类问题通常需要引入的修改比较大,但是收益也会很高,而且长期来看,对于代码的可维护性和缺陷率也会带来好的回报。 因此,这一步的目标是识别出哪些问题可以通过简单修改就可以达成,而另外的一些则需要大的改动。事实是,有可能对于我们之前定义好的基线,只需要解决纯粹的性能问题就可以达成,那我们也无需花费大量的工作在更大的修改上。 总纲 或曰,性能优化之诀窍,唯推拖二字也。推者,不是我的事儿我绝不干,谁爱干谁干。拖者,能明天做的事儿,今天绝不去碰。 如果纯粹的最佳实践无法满足要求,我们则需要花费更多的时间来重构代码的设计来满足性能需求。 我们将通过一些具体的例子来仔细讨论。总的来说,我们需要识别代码中的耦合问题,并在合理的方向上进行抽象,并完成拆分,使得每个独立的模块/组件都尽可能的高内聚,低耦合。 拆字诀 比如在文中讨论的Avatar和Tooltip的例子,头像组件Avartar的核心功能并不包含Tooltip,而且两者的耦合程度其实很低,可以通过拆分的方式将其隔离。 修改后的Avatar不再将Tooltip做为依赖: import Avatar from "@atlaskit/avatar"; import Tooltip from "@material-ui/core/Tooltip"; const MyAvatar = (props) => ( <Tooltip title="Juntao Qiu" placement="top" classes={...}> <Avatar name="Juntao Qiu" url="https://avatars.githubusercontent.com/u/122324" /> </Tooltip> ); 分字诀 在另外一些情况下,一个组件和其依赖间的耦合较为紧密,但是又不具备不可替代性。比如在文中讨论的InlineEdit和InlineDialog的场景。 这时候可以通过render props来进行控制反转,使得组件不再依赖于某个具体实现,而是一个接口。这样所有实现了该接口的组件都可以即插即用,又可以节省默认依赖的部分开销(定义在package.json中的)。 注意这种场景和“拆字诀”里的场景非常类似,不过区别是这里拆分出去的组件和当前组件间有一个隐式的协定:即需要接受render传递过去的所有参数。 const MyEdit = () => { return ( <InlineEdit editView={(fieldProps, isInvalid, error) => ( <Popover open={isInvalid}> <Typography>{error}</Typography> <Textfield {....

June 16, 2021 · 2 min · 邱俊涛 | Juntao Qiu

组件设计之组合原则

前言 在过去的几个月里,我和客户团队在对一个组件库Atlaskit进行优化。表面上看起来这个优化工作包括两大部分:性能优化和结构重整。不过经过这几个月对十多个组件的重构之后,我们发现这两部分工作在很大程度上是同一件事的两个方面:好的设计往往可以带来更好的性能,反之亦然。 这是一个非常有趣的发现,我们在讨论性能优化的时候,一个经常被忽略的因素恰恰是软件本身的设计。我们会关注文件大小,是否会有多重渲染,甚至一些细节如CSS selector的优先级等等,但是很少为了性能而审视代码的设计。另一方面,如果一个组件写的不符合S.O.L.I.D原则,我们会认为它的可扩展性不够好,或者由于文件体量过大,且职责不清而变得难以维护,但是往往不会认为糟糕的设计会对性能造成影响(也可能是由于性能总是在实现已经完成之后才被注意到)。 为了更好的说明这个问题,以及如何在实践中修改我们的设计,使得代码更可能具有比较优秀的性能,我们可以一起讨论几个典型的例子。 头像组件Avatar 在Atlaskit比较早的一个版本中,头像Avatar组件有一个很方便的功能:如果给Avatar传入了name属性,则当鼠标悬停到头像时,头像下方会显示一个提示信息(Tooltip),内容为对应的name。 在实现中,Avatar使用了另一个组件Tooltip来完成这个功能: import Tooltip from "@atlaskit/tooltip"; const Avatar = (props) => { if (props.name) { return ( <Tooltip content={props.name}> <Circle> <img src={props.url} /> </Circle> </Tooltip> ); } return ( <Circle> <img src={props.url} /> </Circle> ); }; 这个功能本身并没有问题,不过当用户提出更多的需求后,我们就开始失去了对Avatar的控制。比如用户A希望鼠标悬停的时候,Tooltip可以显示在头像的上方。而用户B则希望可以定制Tooltip的背景色/字体/字号等等。 当然,我们可以开放一些新的参数给Avatar来实现这些需求,比如: <Avatar tooltipPosition="top" tooltipBackgroundColor="blue" tooltipColor="whitesmoke" />; 或者更进一步,开放一个选项对象: <Avatar tooltipProps={{ position: "top", backgroundColor: "blue", color: "whitesmoke", }} />; 然后在实现中我们将其透传给Tooltip组件。不过很快我们会发现这样的方式会带来一些问题: 由于Avatar依赖于Tooltip,打包后文件的尺寸会增加 如果用户需要以新的方式定制Tooltip,Avatar的接口也需要相应的更新 由于这个依赖,当Tooltip的API变化时,Avatar需要重新打包 而如果我们审视Avatar组件的话,会发现Tooltip对其核心功能(显示用户头像)来说,更像是起到了辅助作用,而并非不可或缺。比如,假设不使用Tooltip组件,我们可以把Avatar简化为: const Avatar = (props) => ( <Circle> <img src={props....

May 1, 2021 · 3 min · 邱俊涛 | Juntao Qiu