这篇文章主要参考了 Vue.js 核心成员 Guillaume Chau 在 19 年美国的 Vue conf 分享的主题:9 Performance secrets revealed,分享中提到了九个 Vue.js 性能优化的技巧。 我看完他的分享 PPT后,也阅读了相关的项目源码,在深入了解它的优化原理后,把其中一些优化技巧也应用到了我平时的工作中,取得了相当不错的效果。 这个分享可谓是非常实用了,但是知道和关注的人似乎并不多,到目前为止,该项目也只有可怜的几百个 star。虽然距大佬的分享已经有两年时间,但是其中的优化技巧并没有过时,为了让更多的人了解并学习到其中的实用技巧,我决定对他的分享做二次加工,详细阐述其中的优化原理,并做一定程度的扩展和延伸。 本文主要还是针对 Vue.js 2.x 版本,毕竟接下来一段时间,Vue.js 2.x 还是我们工作中的主流版本。 我建议你在学习这篇文章的时候可以拉取项目的源码,并且本地运行,查看优化前后的效果差异。 Functional components第一个技巧,函数式组件,你可以查看这个在线示例 优化前的组件代码如下: <template> <div class="cell"> <div v-if="value" class="on"></div> <section v-else class="off"></section> </div> </template> <script> export default { props: ['value'], } </script> 优化后的组件代码如下: <template functional> <div class="cell"> <div v-if="props.value" class="on"></div> <section v-else class="off"></section> </div> </template> 然后我们在父组件各渲染优化前后的组件 800 个,并在每一帧内部通过修改数据来触发组件的更新,开启 Chrome 的 Performance 面板记录它们的性能,得到如下结果。 我们可以看到优化前执行 那么,为什么用函数式组件 JS 的执行时间就变短了呢?这要从函数式组件的实现原理说起了,你可以把它理解成一个函数,它可以根据你传递的上下文数据渲染生成一片 DOM。 函数式组件和普通的对象类型的组件不同,它不会被看作成一个真正的组件,我们知道在 因此,函数式组件也不会有状态,不会有响应式数据,生命周期钩子函数这些东西。你可以把它当成把普通组件模板中的一部分 DOM 剥离出来,通过函数的方式渲染出来,是一种在 DOM 层面的复用。 Child component splitting第二个技巧,子组件拆分,你可以查看这个在线示例。 优化前的组件代码如下: <template> <div :style="{ opacity: number / 300 }"> <div>{{ heavy() }}</div> </div> </template> <script> export default { props: ['number'], methods: { heavy () { const n = 100000 let result = 0 for (let i = 0; i < n; i++) { result += Math.sqrt(Math.cos(Math.sin(42))) } return result } } } </script> 优化后的组件代码如下: <template> <div :style="{ opacity: number / 300 }"> <ChildComp/> </div> </template> <script> export default { components: { ChildComp: { methods: { heavy () { const n = 100000 let result = 0 for (let i = 0; i < n; i++) { result += Math.sqrt(Math.cos(Math.sin(42))) } return result }, }, render (h) { return h('div', this.heavy()) } } }, props: ['number'] } </script> 然后我们在父组件各渲染优化前后的组件 300 个,并在每一帧内部通过修改数据来触发组件的更新,开启 Chrome 的 Performance 面板记录它们的性能,得到如下结果。 我们可以看到优化后执行 那么为什么会有差异呢,我们来看优化前的组件,示例通过一个 而优化后的方式是把这个耗时任务 不过针对这个优化的方式我提出了一些不同的看法,详情可以点开这个 issue,我认为这个场景下的优化用计算属性要比子组件拆分要好。得益于计算属性自身缓存特性,耗时的逻辑也只会在第一次渲染的时候执行,而且使用计算属性也没有额外渲染子组件的开销。 在实际工作中,使用计算属性是优化性能的场景会有很多,毕竟它也体现了一种空间换时间的优化思想。 Local variables第三个技巧,局部变量,你可以查看这个在线示例。 优化前的组件代码如下: <template> <div :style="{ opacity: start / 300 }">{{ result }}</div> </template> <script> export default { props: ['start'], computed: { base () { return 42 }, result () { let result = this.start for (let i = 0; i < 1000; i++) { result += Math.sqrt(Math.cos(Math.sin(this.base))) + this.base * this.base + this.base + this.base * 2 + this.base * 3 } return result }, }, } </script> 优化后的组件代码如下: <template> <div :style="{ opacity: start / 300 }">{{ result }}</div> </template> <script> export default { props: ['start'], computed: { base () { return 42 }, result ({ base, start }) { let result = start for (let i = 0; i < 1000; i++) { result += Math.sqrt(Math.cos(Math.sin(base))) + base * base + base + base * 2 + base * 3 } return result }, }, } </script> 然后我们在父组件各渲染优化前后的组件 300 个,并在每一帧内部通过修改数据来触发组件的更新,开启 Chrome 的 Performance 面板记录它们的性能,得到如下结果。 我们可以看到优化后执行 这里主要是优化前后组件的计算属性 那么为啥这个差异会造成性能上的差异呢,原因是你每次访问 从需求上来说, 这是一个非常实用的性能优化技巧。因为很多人在开发 Vue.js 项目的时候,每当取变量的时候就习惯性直接写 我之前给 ZoomUI 的 Table 组件做性能优化的时候,在 Reuse DOM with v-show第四个技巧,使用 优化前的组件代码如下: <template functional> <div class="cell"> <div v-if="props.value" class="on"> <Heavy :n="10000"/> </div> <section v-else class="off"> <Heavy :n="10000"/> </section> </div> </template> 优化后的组件代码如下: <template functional> <div class="cell"> <div v-show="props.value" class="on"> <Heavy :n="10000"/> </div> <section v-show="!props.value" class="off"> <Heavy :n="10000"/> </section> </div> </template> 然后我们在父组件各渲染优化前后的组件 200 个,并在每一帧内部通过修改数据来触发组件的更新,开启 Chrome 的 Performance 面板记录它们的性能,得到如下结果。 我们可以看到优化后执行 优化前后的主要区别是用
function render() { with(this) { return _c('div', { staticClass: "cell" }, [(props.value) ? _c('div', { staticClass: "on" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1) : _c('section', { staticClass: "off" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1)]) } } 当条件 因此使用 而当我们使用 function render() { with(this) { return _c('div', { staticClass: "cell" }, [_c('div', { directives: [{ name: "show", rawName: "v-show", value: (props.value), expression: "props.value" }], staticClass: "on" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1), _c('section', { directives: [{ name: "show", rawName: "v-show", value: (!props.value), expression: "!props.value" }], staticClass: "off" }, [_c('Heavy', { attrs: { "n": 10000 } })], 1)]) } } 当条件 原来在 因此相比于 但是 在使用 因此你要搞清楚它们的原理以及差异,才能在不同的场景使用适合的指令。 KeepAlive第五个技巧,使用 优化前的组件代码如下: <template> <div id="app"> <router-view/> </div> </template> 优化后的组件代码如下: <template> <div id="app"> <keep-alive> <router-view/> </keep-alive> </div> </template> 我们点击按钮在 Simple page 和 Heavy Page 之间切换,会渲染不同的视图,其中 Heavy Page 的渲染非常耗时。我们开启 Chrome 的 Performance 面板记录它们的性能,然后分别在优化前后执行如上的操作,会得到如下结果。 我们可以看到优化后执行 在非优化场景下,我们每次点击按钮切换路由视图,都会重新渲染一次组件,渲染组件就会经过组件初始化, 而在使用 但是使用 Deferred features第六个技巧,使用 优化前的组件代码如下: <template> <div class="deferred-off"> <VueIcon icon="fitness_center" class="gigantic"/> <h2>I'm an heavy page</h2> <Heavy v-for="n in 8" :key="n"/> <Heavy class="super-heavy" :n="9999999"/> </div> </template> 优化后的组件代码如下: <template> <div class="deferred-on"> <VueIcon icon="fitness_center" class="gigantic"/> <h2>I'm an heavy page</h2> <template v-if="defer(2)"> <Heavy v-for="n in 8" :key="n"/> </template> <Heavy v-if="defer(3)" class="super-heavy" :n="9999999"/> </div> </template> <script> import Defer from '@/mixins/Defer' export default { mixins: [ Defer(), ], } </script> 我们点击按钮在 Simple page 和 Heavy Page 之间切换,会渲染不同的视图,其中 Heavy Page 的渲染非常耗时。我们开启 Chrome 的 Performance 面板记录它们的性能,然后分别在优化前后执行如上的操作,会得到如下结果。我们可以发现,优化前当我们从 Simple Page 切到 Heavy Page 的时候,在一次 Render 接近结尾的时候,页面渲染的仍然是 Simple Page,会给人一种页面卡顿的感觉。而优化后当我们从 Simple Page 切到 Heavy Page 的时候,在一次 Render 靠前的位置页面就已经渲染了 Heavy Page 了,并且 Heavy Page 是渐进式渲染出来的。 优化前后的差距主要是后者使用了 export default function (count = 10) { return { data () { return { displayPriority: 0 } }, mounted () { this.runDisplayPriority() }, methods: { runDisplayPriority () { const step = () => { requestAnimationFrame(() => { this.displayPriority++ if (this.displayPriority < count) { step() } }) } step() }, defer (priority) { return this.displayPriority >= priority } } } }
当你有渲染耗时的组件,使用 Time slicing第七个技巧,使用 优化前的代码如下: fetchItems ({ commit }, { items }) { commit('clearItems') commit('addItems', items) } 优化后的代码如下: fetchItems ({ commit }, { items, splitCount }) { commit('clearItems') const queue = new JobQueue() splitArray(items, splitCount).forEach( chunk => queue.addJob(done => { // 分时间片提交数据 requestAnimationFrame(() => { commit('addItems', chunk) done() }) }) ) await queue.start() } 我们先通过点击 优化前: 我们可以发现,优化前总的 那么为什么在优化前页面会卡死呢?因为一次性提交的数据过多,内部 JS 执行时间过长,阻塞了 UI 线程,导致页面卡死。 优化后,页面仍有卡顿,是因为我们拆分数据的粒度是 1000 条,这种情况下,重新渲染组件仍然有压力,我们观察 fps 只有十几,会有卡顿感。通常只要让页面的 fps 达到 60,页面就会非常流畅,如果我们把数据拆分粒度变成 100 条,基本上 fps 能达到 50 以上,虽然页面渲染变流畅了,但是完成 10000 条数据总的提交时间还是变长了。 使用 这里要注意的一点,虽然我们拆时间片使用了 Non-reactive data第八个技巧,使用 优化前代码如下: const data = items.map( item => ({ id: uid++, data: item, vote: 0 }) ) 优化后代码如下: const data = items.map( item => optimizeItem(item) ) function optimizeItem (item) { const itemData = { id: uid++, vote: 0 } Object.defineProperty(itemData, 'data', { // Mark as non-reactive configurable: false, value: item }) return itemData } 还是前面的示例,我们先通过点击 我们可以看到优化后执行 之所以有这种差异,是因为内部提交的数据的时候,会默认把新提交的数据也定义成响应式,如果数据的子属性是对象形式,还会递归让子属性也变成响应式,因此当提交数据很多的时候,这个过程就变成了一个耗时过程。 而优化后我们把新提交的数据中的对象属性 其实类似这种优化的方式还有很多,比如我们在组件中定义的一些数据,也不一定都要在 export default { created() { this.scroll = null }, mounted() { this.scroll = new BScroll(this.$el) } } 这样我们就可以在组件上下文中共享 Virtual scrolling第九个技巧,使用 优化前组件的代码如下: <div class="items no-v"> <FetchItemViewFunctional v-for="item of items" :key="item.id" :item="item" @vote="voteItem(item)" /> </div> 优化后代码如下: <recycle-scroller class="items" :items="items" :item-size="24" > <template v-slot="{ item }"> <FetchItemView :item="item" @vote="voteItem(item)" /> </template> </recycle-scroller> 还是前面的示例,我们需要开启 我们发现,在非优化的情况下,10000 条数据在滚动情况下 fps 只有个位数,在非滚动情况下也就十几,原因是非优化场景下渲染的 DOM 太多,渲染本身的压力很大。优化后,即使 10000 条数据,在滚动情况下的 fps 也能有 30 多,在非滚动情况下可以达到 60 满帧。 之所以有这个差异,是因为虚拟滚动的实现方式:是只渲染视口内的 DOM。这样总共渲染的 DOM 数量就很少了,自然性能就会好很多。 虚拟滚动组件也是 Guillaume Chau 写的,感兴趣的同学可以去研究它的源码实现。它的基本原理就是监听滚动事件,动态更新需要显示的 DOM 元素,计算出它们在视图中的位移。 虚拟滚动组件也并非没有成本,因为它需要在滚动的过程中实时去计算,所以会有一定的 总结通过这篇文章,我希望你能了解到 Vue.js 的九种性能优化技巧,并能运用到实际的开发项目中。除了上述技巧之外,还有懒加载图片、懒加载组件、异步组件等等常用的性能优化手段。 在做性能优化前,我们需要分析性能的瓶颈在哪,才能因地制宜。另外,性能优化都需要数据支撑的,你在做任何性能优化前,需要先采集优化前的数据,这样优化后才能够通过数据对比看到优化的效果。 希望你在日后的开发过程中,不再只满足于实现需求,写每一行代码的时候,都能思考它可能产生的性能方面的影响。 |
|