This post references the topic shared by Guillaume Chau, a core Vue.js member, at Vue conf ’19 in the U.S.: 9 Performance secrets revealed, in which nine Vue.js performance optimization tips were mentioned.
After I watched his PPT, I also read the source code of the project, and after I deeply understood the optimization principle, I applied some of the optimization techniques to my work, and achieved quite good results.
This share can be said to be very practical, but it seems that not many people know and pay attention to, so far, the project is only poor a few hundred stars. although from the big brother’s share has been two years, but the optimization techniques are not out of date, in order to let more people understand and learn the practical skills, I decided to do a second processing of his share, elaborate on the optimization principles and do a certain degree of expansion and extension. Do a certain degree of expansion and extension.
I suggest you pull the source code of your project and run it locally to see the difference in results before and after optimization while studying this article.
Functional components
For the first trick, functional components, you can check out this online example.
The component code before optimization is as follows:
<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>
The optimized component code is as follows:
<template functional>
<div class="cell">
<div v-if="props.value" class="on"></div>
<section v-else class="off"></section>
</div>
</template>
We then rendered 800 components before and after the optimization in each of the parent components, and triggered updates to the components by modifying the data inside each frame, and turned on Chrome’s Performance panel to record their performance to get the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the execution time of script
before optimization is more than after optimization, and we know that the JS engine is a single-threaded operation mechanism, the JS thread will block the UI thread, so when the script execution time is too long, it will block the rendering, resulting in the page lagging. The optimized script
has a short execution time, so it performs better.
So why is JS execution time shorter with functional components? It starts with the implementation of a functional component, which you can think of as a function that renders a piece of DOM based on the contextual data you pass it.
Functional components and ordinary object type components are different, it will not be seen as a real component, we know that in the patch
process, if a node is encountered is a component vnode
, will recursively perform the initialization process of the sub-component; and functional components of render
generated by the ordinary vnode
, there will be no recursive sub-component process, so the rendering overhead will be much lower.
So a functional component also won’t have state, it won’t have responsive data, lifecycle hook functions, that kind of stuff. You can think of it as stripping out a portion of the DOM from a normal component template and rendering it as a function, a kind of reuse at the DOM level.
Child component splitting
For the second technique, subcomponent splitting, you can check out this online example.
The component code before optimization is as follows:
<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>
The optimized component code is as follows:
<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>
We then rendered 300 components before and after the optimization in each of the parent components, and triggered updates to the components by modifying the data inside each frame, and turned on Chrome’s Performance pane to record their performance, which yielded the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the time to execute script
after optimization is significantly less than that before optimization, resulting in a better performance experience.
So why the difference? Let’s look at the component before optimization. The example simulates a time-consuming task through a heavy
function and this function is executed once per rendering, so each rendering of the component consumes a long time to execute JavaScript.
The optimized way to do this is to encapsulate the execution logic of this time-consuming task heavy
function in a child component ChildComp
. Since Vue’s updates are component-granular, while each frame causes the parent component to re-render through data modification, ChildComp
does not re-render because it doesn’t have any responsive data changes inside it either. So the optimized component doesn’t perform time-consuming tasks on every render, naturally executing less JavaScript time.
However, I have a different opinion on how this optimization should be done, which can be found in this issue, and I think that the optimization in this scenario is better done with computed attributes than with subcomponent splitting. Thanks to the caching nature of computed attributes, time-consuming logic is only executed on the first render, and there is no additional overhead of rendering subcomponents with computed attributes.
In practice, there will be many scenarios where the use of computational attributes is a way to optimize performance; after all, it also embodies a space-for-time optimization idea.
Local variables
For the third trick, local variables, you can check out this online example.
The component code before optimization is as follows:
<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>
The optimized component code is as follows:
<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>
We then rendered 300 components before and after the optimization in each of the parent components, and triggered updates to the components by modifying the data inside each frame, and turned on Chrome’s Performance pane to record their performance, which yielded the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the time to execute script
after optimization is significantly less than before optimization, resulting in a better performance experience.
The main difference here is the implementation of the computation property result
for the component before and after the optimization. The component before the optimization accesses this.base
several times during the computation, while the component after the optimization will use the local variable base
before the computation, cache this.base
, and directly access base
later.
The reason why this difference will cause a performance difference is that every time you visit this.base
, since this.base
is a responsive object, it will trigger its getter
, which in turn will execute the dependency collection related logic code. If you execute similar logic more than once, like the example, you will loop through hundreds of components hundreds of times, each component will trigger computed
to recalculate, and then you will execute the dependency collection logic many times, so the performance will naturally drop.
In terms of requirements, this.base
performs dependency collection once, and returns the result of its getter
request to the local variable base
, so that subsequent visits to base
will not trigger getter
, and will not follow the logic of dependency collection, which naturally improves performance.
This is a very useful performance optimization tip. Because many people in the development of Vue.js projects, whenever they fetch a variable, it is customary to write this.xxx
directly, because most people do not notice what they are doing behind the scenes when they access this.xxx
. When the number of accesses is small, the performance problem is not emphasized, but once the number of accesses becomes large, such as multiple accesses in a large loop, similar to the scenario in the example, performance problems will arise.
When I was optimizing the performance of ZoomUI’s Table component, I used the local variable optimization technique at render table body
and wrote a benchmark to compare the performance: when rendering a table of 1000 * 10, the performance of ZoomUI Table’s data update and re-rendering is nearly double that of ElementUI’s Table.
Reuse DOM with v-show
For a fourth trick, reusing the DOM using v-show
, you can check out this online example.
The component code before optimization is as follows:
<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>
The optimized component code is as follows:
<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>
We then rendered 200 components before and after the optimization in each of the parent components, and triggered updates to the components by modifying the data inside each frame, and turned on Chrome’s Performance pane to record their performance, which yielded the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the time to execute script
after optimization is significantly less than that before optimization, resulting in a better performance experience.
The main difference before and after the optimization is that the v-show
instruction replaces the v-if
instruction to replace the component’s manifestation. Although v-show
and v-if
are similar in terms of performance, both of them control the manifestation of the component, but the internal implementation gap is still very big.
v-if
The directive is compiled into a ternary operator at the compilation stage, conditional rendering, for example, the component template before optimization is compiled to produce the following rendering function:
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)])
}
}
When the value of the condition props.value
changes, it will trigger the corresponding component update. For the node rendered by v-if
, due to the inconsistency between the old and new node vnode
, in the core diff algorithm comparison process, it will remove the old vnode
node and create the new vnode
node, then it will create the new Heavy
component, and it will go through the process of initialization, rendering of vnode
, patch
, etc. for the Heavy
component itself.
Therefore using v-if
creates new Heavy
subcomponents every time the component is updated, which naturally causes performance pressure when more components are updated.
And when we use the v-show
directive, the optimized component template is compiled to produce the following render function:
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)])
}
}
When the value of the condition props.value
changes, it triggers an update of the corresponding component. For nodes rendered by v-show
, since the old and new vnode
are consistent, they only need to be patchVnode
all the time, so how does it make the DOM nodes show and hide?
It turns out that during the patchVnode
process, the hook function update
is internally executed corresponding to the v-show
directive, and then it sets the value of style.display
to the value of the DOM element it acts on according to the value bound by the v-show
directive.
So compared to v-if
which is constantly deleting and creating new DOMs as functions, v-show
is only updating the explicit and implicit values of existing DOMs, so v-show
has much less overhead than v-if
, and the difference in performance increases as its internal DOM structure becomes more complex.
However, the performance advantage of v-show
over v-if
is in the update phase of the component. If it’s just in the initialization phase, v-if
outperforms v-show
because it only renders one branch, whereas v-show
renders both branches and controls the rendering and hiding of the corresponding DOM through style.display
.
When using v-show
, all components inside the branch are rendered and the corresponding lifecycle hook functions are executed, whereas when using v-if
, components inside the branch with no hits are not rendered and the corresponding lifecycle hook functions are not executed.
So you need to figure out how they work and how they differ in order to use the right commands for different scenarios.
KeepAlive
For the fifth trick, using the KeepAlive
component to cache the DOM, you can check out this online example.
The component code before optimization is as follows:
<template>
<div id="app">
<router-view/>
</div>
</template>
The optimized component code is as follows:
<template>
<div id="app">
<keep-alive>
<router-view/>
</keep-alive>
</div>
</template>
When we click the button to switch between Simple page and Heavy Page, different views are rendered, with the Heavy Page rendering being very time consuming. We turn on Chrome’s Performance panel to record their performance, and then we do the above before and after optimization, and we get the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the time to execute script
after optimization is significantly less than that before optimization, resulting in a better performance experience.
In non-optimized scenarios, every time we click a button to switch the routing view, we will re-render the component, and the rendered component will go through the process of component initialization, render
, patch
, etc. If the component is more complex, or deeply nested, the whole rendering will take a long time.
After using KeepAlive
, after the first rendering of the component wrapped by KeepAlive
, the vnode
and DOM of the component will be cached, and then the next time the component is rendered again, it will get the corresponding vnode
and DOM directly from the cache, and then render it, and it doesn’t need to go through a series of processes such as initialization of the component, render
, patch
, etc., which reduces the execution time of script
, and results in better performance.
But using the KeepAlive
component is not without cost, as it takes up more memory for caching, a typical application of the space-for-time optimization idea.
Deferred features
For a sixth trick, use the Deferred
component to delay the rendering of components in batches, you can check this online example.
The component code before optimization is as follows:
<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>
The optimized component code is as follows:
<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>
When we click the button to switch between Simple page and Heavy Page, different views are rendered, with the Heavy Page rendering being very time consuming. We turn on Chrome’s Performance panel to record their performance, and then we do the above before and after optimization, and we get the following results.
Pre-optimization:
Optimized:
Comparing these two images, we can see that before optimization, when we cut from Simple Page to Heavy Page, the page is still rendered as Simple Page near the end of a Render, which gives a feeling of page lag. After optimization, when we cut from Simple Page to Heavy Page, the Heavy Page is rendered at the beginning of a Render, and the Heavy Page is rendered incrementally.
The difference between before and after the optimization is mainly the latter uses Defer
this mixin
, so how exactly does it work, let’s find out:
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
}
}
}
}
Defer
The main idea is to split a rendering of a component into multiple renderings. It internally maintains the displayPriority
variable, which is then incremented at each rendering frame by requestAnimationFrame
, up to count
. A component that uses Defer mixin
can then internally control the rendering of certain blocks when displayPriority
is incremented to xxx
by means of v-if="defer(xxx)"
.
When you have components that are time-consuming to render, it’s good to note that using Deferred
for progressive rendering prevents the rendering from getting stuck due to the long JS execution time of render
once.
Time slicing
For the seventh tip, using the Time slicing
time-slice cutting technique, you can view this online example.
The code before optimization is as follows:
fetchItems ({ commit }, { items }) {
commit('clearItems')
commit('addItems', items)
}
The optimized code is as follows:
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()
}
If we create 10,000 dummy data by clicking on the Genterate items
button, then submit them by clicking on the Commit items
button with Time-slicing
on and off, and turn on Chrome’s Performance pane to record their performance, we’ll get the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the total script
execution time before optimization is a bit less than after optimization, but from the actual view, clicking the submit button before optimization, the page will be stuck for about 1.2 seconds, after optimization, the page won’t be completely stuck, but there will still be the feeling of rendering lag.
So why does the page get stuck before optimization? Because too much data is submitted at once, the internal JS execution time is too long, blocking the UI thread and causing the page to crash.
After optimization, the page still lags because the granularity of the data we split is 1000 items, in this case, re-rendering the component is still under pressure, and we observe that the fps is only a dozen or so, which makes the page feel laggy. Usually, if the fps of the page reaches 60, the page will be very smooth. If we change the granularity of data splitting to 100 items, basically, the fps can reach more than 50, although the page rendering becomes smoother, the total submission time for completing 10,000 items of data is still longer.
Using the Time slicing
technique can avoid the page from getting stuck, usually we will add a loading effect when we process this kind of time-consuming task, in this example, we can turn on loading animation
and then submit the data. In this example, we can turn on and then submit the data. Comparison shows that before optimization, due to too much data submitted at one time, the JS has been running for a long time, blocking the UI threads, and this loading animation won’t be shown, while after optimization, due to the fact that we split it into multiple time slices to submit the data, the single JS runtime becomes shorter, and the loading animation has a chance to be shown.
One thing to note here is that although we use therequestAnimationFrame
API for time slicing, usingrequestAnimationFrame
itself doesn’t guarantee full-frame operation.requestAnimationFrame
guarantees that the corresponding incoming callback function will be executed after every redraw of the browser, so if you want to guarantee full-frame operation, you can only let the JS run for no more than 17ms within a Tick.
Non-reactive data
For the eighth tip, using Non-reactive data
non-responsive data, you can check out this online example.
The code before optimization is as follows:
const data = items.map(
item => ({
id: uid++,
data: item,
vote: 0
})
)
The optimized code is as follows:
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
}
As in the previous example, we create 10,000 dummy data items by clicking on the Genterate items
button, then submit them by clicking on the Commit items
button with Partial reactivity
turned on and off, and turn on Chrome’s Performance pane to record their performance, which gives us the following results.
Pre-optimization:
Optimized:
Comparing these two graphs we can see that the time to execute script
after optimization is significantly less than before optimization, resulting in a better performance experience.
The reason for this difference is that when data is submitted internally, it will by default define the newly submitted data as responsive as well, and if the data’s sub-properties are in the form of objects, it will also recursively make the sub-properties responsive as well, and thus when there are a lot of submissions, this process becomes a time-consuming process.
And after optimization, we put the newly submitted data in the object attribute data
manually become configurable
for false
, so that the internal at walk
when the object attribute array through Object.keys(obj)
will ignore data
, will not be data
this attribute defineReactive
, due to data
pointing to an object, which will reduce the recursive responsive logic, which is equivalent to reduce the performance of this part of the loss. The larger the amount of data, the more obvious the effect of this optimization will be.
In fact, there are many other ways to optimize like this, for example, some of the data we define in the component doesn’t always have to be defined in data
. Some data we don’t use in templates and we don’t need to listen to its changes, we just want to share this data in the context of the component, at this time we can just mount this data to the component instance this
, for example:
export default {
created() {
this.scroll = null
},
mounted() {
this.scroll = new BScroll(this.$el)
}
}
This allows us to share the scroll
object in the component context, even though it is not a responsive object.
Virtual scrolling
For the ninth tip, using the Virtual scrolling
virtual scroll component, you can check out this online example.
The code of the component before optimization is as follows:
<div class="items no-v">
<FetchItemViewFunctional
v-for="item of items"
:key="item.id"
:item="item"
@vote="voteItem(item)"
/>
</div>
The optimized code is as follows:
<recycle-scroller
class="items"
:items="items"
:item-size="24"
>
<template v-slot="{ item }">
<FetchItemView
:item="item"
@vote="voteItem(item)"
/>
</template>
</recycle-scroller>
As in the previous example, we need to turn on View list
and click the Genterate items
button to create 10,000 pieces of dummy data (note that the online example can only create a maximum of 1,000 pieces of data, and in fact 1,000 pieces of data is not a good representation of the optimization, so I changed the limit in the source code, ran it locally, and created 10,000 pieces of data), and then clicked on the Commit items
button to submit the data in the case of Unoptimized
and RecycleScroller
, and then scrolled down the page, open Chrome’s Performance panel to record their performance, you will get the following results.
Pre-optimization:
Optimized:
Comparing these two graphs, we see that in the non-optimized scenario, the fps for 10,000 pieces of data is in the single digits in the scrolling scenario, and in the teens in the non-scrolling scenario, because the non-optimized scenario renders too much DOM, which puts a lot of pressure on the rendering itself. After optimization, even with 10000 data items, the fps in scrolling case is still more than 30, and in non-scrolling case, it can reach 60 full frames.
The reason for this difference is that virtual scrolling is implemented in such a way that only DOMs within the viewport are rendered, so the total number of DOMs rendered is very small, and naturally performance is much better.
The virtual scroll component is also written by Guillaume Chau, if you are interested, you can study its source code implementation. The basic principle is to listen for scroll events, dynamically update the DOM elements to be displayed, and calculate their displacement in the view.
The virtual scroll component is not without cost, as it needs to be calculated in real time during the scrolling process, so there will be a certain cost of script
execution. Therefore, if the amount of data in the list is not very large, it is sufficient to use the normal scrolling.