效能最佳化方法有:1、使用“v-slot:slotName”,而不是“slot="slotName"”;2、避免“v-for”和“v-if”同時使用;3 、始終為「v-for」添加key,並且不要將index作為的key;4、使用延遲渲染等等。
本教學操作環境:windows7系統、vue2.9.6版,DELL G3電腦。
我們在使用Vue 或其他框架的日常開發中,或多或少的都會遇到一些效能問題,儘管Vue 內部已經幫助我們做了許多優化,但是還是有些問題是需要我們主動去避免的。我在我的日常開中,以及網上各種大佬的文章中總結了一些容易產生性能問題的場景以及針對這些問題優化的技巧,這篇文章就來探討下,希望對你有所幫助。
v-slot:slotName
,而不是slot="slotName"
v-slot
是2.6 新增的語法,具體可查看:Vue2.6,2.6 發布已經是快兩年前的事情了,但是現在仍然有不少人仍然在使用slot="slotName"
這個語法。雖然這兩個語法都能達到相同的效果,但是內部的邏輯確實不一樣的,以下來看這兩種方式有什麼不同之處。
我們先來看下這兩種語法分別會被編譯成什麼:
使用新的寫法,對於父元件中的以下模板:
<child> <template v-slot:name>{{name}}</template> </child>
會被編譯成:
function render() { with (this) { return _c('child', { scopedSlots: _u([ { key: 'name', fn: function () { return [_v(_s(name))] }, proxy: true } ]) }) } }
使用舊的寫法,對於以下模板:
<child> <template slot="name">{{name}}</template> </child>
會被編譯成:
function render() { with (this) { return _c( 'child', [ _c( 'template', { slot: 'name' }, [_v(_s(name))] ) ], ) } }
透過編譯後的程式碼可以發現,舊的寫法是將插槽內容作為children 渲染的,會在父組件的渲染函數中創建,插槽內容的依賴會被父組件收集(name 的dep 收集到父組件的渲染watcher),而新的寫法將插槽內容放在了scopedSlots 中,會在子元件的渲染函數中調用,插槽內容的依賴會被子元件收集(name 的dep 收集到子元件的渲染watcher),最終導致的結果就是:當我們修改name 這個屬性時,舊的寫法是呼叫父元件的更新(呼叫父元件的渲染watcher),然後在父元件更新過程中呼叫子元件更新(prePatch => updateChildComponent),而新的寫法則是直接呼叫子元件的更新(呼叫子元件的渲染watcher)。
這樣一來,舊的寫法在更新時就多了一個父組件更新的過程,而新的寫法由於直接更新子組件,就會更加高效,性能更好,所以推薦始終使用v-slot:slotName
語法。
這一點已經被提及很多次了,計算屬性最大的一個特點就是它是可以被快取的,這個快取指的是只要它的依賴的不會改變,它就不會被重新求值,再次訪問時會直接拿到快取的值,在做一些複雜的計算時,可以大大提升效能。可以看以下程式碼:
<template> <div>{{superCount}}</div> </template> <script> export default { data() { return { count: 1 } }, computed: { superCount() { let superCount = this.count // 假设这里有个复杂的计算 for (let i = 0; i < 10000; i++) { superCount++ } return superCount } } } </script>
這個例子中,在created、mounted 以及模板中都訪問了superCount 屬性,這三次訪問中,實際上只有第一次即created
時才會對superCount 求值,由於count 屬性並未改變,其餘兩次都是直接傳回快取的value。
對於某些元件,如果我們只是用來顯示一些數據,不需要管理狀態,監聽資料等,那麼就可以用函數式組件。函數式元件是無狀態的,無實例的,在初始化時不需要初始化狀態,不需要建立實例,也不需要去處理生命週期等,比起有狀態元件,會更輕量級,同時效能也更好。具體的函數式元件使用方式可參考官方文件:函數式元件
我們可以寫一個簡單的demo 來驗證下這個最佳化:
// UserProfile.vue <template> <div class="user-profile">{{ name }}</div> </template> <script> export default { props: ['name'], data() { return {} }, methods: {} } </script> <style scoped></style> // App.vue <template> <div id="app"> <UserProfile v-for="item in list" :key="item" : /> </div> </template> <script> import UserProfile from './components/UserProfile' export default { name: 'App', components: { UserProfile }, data() { return { list: Array(500) .fill(null) .map((_, idx) => 'Test' + idx) } }, beforeMount() { this.start = Date.now() }, mounted() { console.log('用时:', Date.now() - this.start) } } </script> <style></style>
UserProfile 這個元件只渲染了props 的name ,然後在App.vue 中呼叫500 次,統計從beforeMount 到mounted 的耗時,即為500 個子元件(UserProfile)初始化的耗時。
經過我多次嘗試後,發現耗時一直在30ms 左右,那麼現在我們再把改成UserProfile 改成函數式元件:
<template functional> <div class="user-profile">{{ props.name }}</div> </template>
此時再經過多次嘗試後,初始化的耗時一直在10-15ms,這些足以說明函數式組件比有狀態組件有著更好的性能。
以下是兩個使用v-show 和v-if 的範本
<template> <div> <UserProfile :user="user1" v-if="visible" /> <button @click="visible = !visible">toggle</button> </div> </template>
<template> <div> <UserProfile :user="user1" v-show="visible" /> <button @click="visible = !visible">toggle</button> </div> </template>
這兩者的作用都是用來控制某些組件或DOM 的顯示/ 隱藏,在討論它們的性能差異之前,先來分析下這兩者有何不同。其中,v-if 的模板會被編譯成:
function render() { with (this) { return _c( 'div', [ visible ? _c('UserProfile', { attrs: { user: user1 } }) : _e(), _c( 'button', { on: { click: function ($event) { visible = !visible } } }, [_v('toggle')] ) ], ) } }
可以看到,v-if 的部分被转换成了一个三元表达式,visible 为 true 时,创建一个 UserProfile 的 vnode,否则创建一个空 vnode,在 patch 的时候,新旧节点不一样,就会移除旧的节点或创建新的节点,这样的话UserProfile
也会跟着创建 / 销毁。如果UserProfile
组件里有很多 DOM,或者要执行很多初始化 / 销毁逻辑,那么随着 visible 的切换,势必会浪费掉很多性能。这个时候就可以用 v-show 进行优化,我们来看下 v-show 编译后的代码:
function render() { with (this) { return _c( 'div', [ _c('UserProfile', { directives: [ { name: 'show', rawName: 'v-show', value: visible, expression: 'visible' } ], attrs: { user: user1 } }), _c( 'button', { on: { click: function ($event) { visible = !visible } } }, [_v('toggle')] ) ], ) } }
v-show
被编译成了directives
,实际上,v-show 是一个 Vue 内部的指令,在这个指令的代码中,主要执行了以下逻辑:
el.style.display = value ? el.__vOriginalDisplay : 'none'
它其实是通过切换元素的 display 属性来控制的,和 v-if 相比,不需要在 patch 阶段创建 / 移除节点,只是根据v-show
上绑定的值来控制 DOM 元素的style.display
属性,在频繁切换的场景下就可以节省很多性能。
但是并不是说v-show
可以在任何情况下都替换v-if
,如果初始值是false
时,v-if
并不会创建隐藏的节点,但是v-show
会创建,并通过设置style.display='none'
来隐藏,虽然外表看上去这个 DOM 都是被隐藏的,但是v-show
已经完整的走了一遍创建的流程,造成了性能的浪费。
所以,v-if
的优势体现在初始化时,v-show
体现在更新时,当然并不是要求你绝对按照这个方式来,比如某些组件初始化时会请求数据,而你想先隐藏组件,然后在显示时能立刻看到数据,这时候就可以用v-show
,又或者你想每次显示这个组件时都是最新的数据,那么你就可以用v-if
,所以我们要结合具体业务场景去选一个合适的方式。
在动态组件的场景下:
<template> <div> <component :is="currentComponent" /> </div> </template>
这个时候有多个组件来回切换,currentComponent
每变一次,相关的组件就会销毁 / 创建一次,如果这些组件比较复杂的话,就会造成一定的性能压力,其实我们可以使用 keep-alive 将这些组件缓存起来:
<template> <div> <keep-alive> <component :is="currentComponent" /> </keep-alive> </div> </template>
keep-alive
的作用就是将它包裹的组件在第一次渲染后就缓存起来,下次需要时就直接从缓存里面取,避免了不必要的性能浪费,在讨论上个问题时,说的是v-show
初始时性能压力大,因为它要创建所有的组件,其实可以用keep-alive
优化下:
<template> <div> <keep-alive> <UserProfileA v-if="visible" /> <UserProfileB v-else /> </keep-alive> </div> </template>
这样的话,初始化时不会渲染UserProfileB
组件,当切换visible
时,才会渲染UserProfileB
组件,同时被keep-alive
缓存下来,频繁切换时,由于是直接从缓存中取,所以会节省很多性能,所以这种方式在初始化和更新时都有较好的性能。
但是keep-alive
并不是没有缺点,组件被缓存时会占用内存,属于空间和时间上的取舍,在实际开发中要根据场景选择合适的方式。
这一点是 Vue 官方的风格指南中明确指出的一点:Vue 风格指南
如以下模板:
<ul> <li v-for="user in users" v-if="user.isActive" :key="user.id"> {{ user.name }} </li> </ul>
会被编译成:
// 简化版 function render() { return _c( 'ul', this.users.map((user) => { return user.isActive ? _c( 'li', { key: user.id }, [_v(_s(user.name))] ) : _e() }), ) }
可以看到,这里是先遍历(v-for),再判断(v-if),这里有个问题就是:如果你有一万条数据,其中只有 100 条是isActive
状态的,你只希望显示这 100 条,但是实际在渲染时,每一次渲染,这一万条数据都会被遍历一遍。比如你在这个组件内的其他地方改变了某个响应式数据时,会触发重新渲染,调用渲染函数,调用渲染函数时,就会执行到上面的代码,从而将这一万条数据遍历一遍,即使你的users
没有发生任何改变。
为了避免这个问题,在此场景下你可以用计算属性代替:
<template> <div> <ul> <li v-for="user in activeUsers" :key="user.id">{{ user.name }}</li> </ul> </div> </template> <script> export default { // ... computed: { activeUsers() { return this.users.filter((user) => user.isActive) } } } </script> 复制代码
这样只会在users
发生改变时才会执行这段遍历的逻辑,和之前相比,避免了不必要的性能浪费。
这一点是 Vue 风格指南中明确指出的一点,同时也是面试时常问的一点,很多人都习惯的将 index 作为 key,这样其实是不太好的,index 作为 key 时,将会让 diff 算法产生错误的判断,从而带来一些性能问题,你可以看下 ssh 大佬的文章,深入分析下,为什么 Vue 中不要用 index 作为 key。在这里我也通过一个例子来简单说明下当 index 作为 key 时是如何影响性能的。
看下这个例子:
const Item = { name: 'Item', props: ['message', 'color'], render(h) { debugger console.log('执行了Item的render') return h('div', { style: { color: this.color } }, [this.message]) } } new Vue({ name: 'Parent', template: ` <div @click="reverse" class="list"> <Item v-for="(item,index) in list" :key="item.id" :message="item.message" :color="item.color" /> </div>`, components: { Item }, data() { return { list: [ { id: 'a', color: '#f00', message: 'a' }, { id: 'b', color: '#0f0', message: 'b' } ] } }, methods: { reverse() { this.list.reverse() } } }).$mount('#app')
这里有一个 list,会渲染出来a b
,点击后会执行reverse
方法将这个 list 颠倒下顺序,你可以将这个例子复制下来,在自己的电脑上看下效果。
我们先来分析用id
作为 key 时,点击时会发生什么,
由于 list 发生了改变,会触发Parent
组件的重新渲染,拿到新的vnode
,和旧的vnode
去执行patch
,我们主要关心的就是patch
过程中的updateChildren
逻辑,updateChildren
就是对新旧两个children
执行diff
算法,使尽可能地对节点进行复用,对于我们这个例子而言,此时旧的children
是:
;[ { tag: 'Item', key: 'a', propsData: { color: '#f00', message: '红色' } }, { tag: 'Item', key: 'b', propsData: { color: '#0f0', message: '绿色' } } ]
执行reverse
后的新的children
是:
;[ { tag: 'Item', key: 'b', propsData: { color: '#0f0', message: '绿色' } }, { tag: 'Item', key: 'a', propsData: { color: '#f00', message: '红色' } } ]
此时执行updateChildren
,updateChildren
会对新旧两组 children 节点的循环进行对比:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 对新旧节点执行patchVnode // 移动指针 } else if (sameVnode(oldEndVnode, newEndVnode)) { // 对新旧节点执行patchVnode // 移动指针 } else if (sameVnode(oldStartVnode, newEndVnode)) { // 对新旧节点执行patchVnode // 移动oldStartVnode节点 // 移动指针 } else if (sameVnode(oldEndVnode, newStartVnode)) { // 对新旧节点执行patchVnode // 移动oldEndVnode节点 // 移动指针 } else { //... } }
通过sameVnode
判断两个节点是相同节点的话,就会执行相应的逻辑:
function sameVnode(a, b) { return ( a.key === b.key && ((a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b)) || (isTrue(a.isAsyncPlaceholder) && a.asyncFactory === b.asyncFactory && isUndef(b.asyncFactory.error))) ) }
sameVnode
主要就是通过 key 去判断,由于我们颠倒了 list 的顺序,所以第一轮对比中:sameVnode(oldStartVnode, newEndVnode)
成立,即旧的首节点和新的尾节点是同一个节点,此时会执行patchVnode
逻辑,patchVnode
中会执行prePatch
,prePatch
中会更新 props,此时我们的两个节点的propsData
是相同的,都为{color: '#0f0',message: '绿色'}
,这样的话Item
组件的 props 就不会更新,Item
也不会重新渲染。再回到updateChildren
中,会继续执行"移动oldStartVnode节点"
的操作,将 DOM 元素。移动到正确位置,其他节点对比也是同样的流程。
可以发现,在整个流程中,只是移动了节点,并没有触发 Item 组件的重新渲染,这样实现了节点的复用。
我们再来看下使用index
作为 key 的情况,使用index
时,旧的children
是:
;[ { tag: 'Item', key: 0, propsData: { color: '#f00', message: '红色' } }, { tag: 'Item', key: 1, propsData: { color: '#0f0', message: '绿色' } } ]
执行reverse
后的新的children
是:
;[ { tag: 'Item', key: 0, propsData: { color: '#0f0', message: '绿色' } }, { tag: 'Item', key: 1, propsData: { color: '#f00', message: '红色' } } ]
这里和id
作为 key 时的节点就有所不同了,虽然我们把 list 顺序颠倒了,但是 key 的顺序却没变,在updateChildren
时sameVnode(oldStartVnode, newStartVnode)
将会成立,即旧的首节点和新的首节点相同,此时执行patchVnode -> prePatch -> 更新props
,这个时候旧的 propsData 是{color: '#f00',message: '红色'}
,新的 propsData 是{color: '#0f0',message: '绿色'}
,更新过后,Item 的 props 将会发生改变,会触发 Item 组件的重新渲染。
这就是 index 作为 key 和 id 作为 key 时的区别,id 作为 key 时,仅仅是移动了节点,并没有触发 Item 的重新渲染。index 作为 key 时,触发了 Item 的重新渲染,可想而知,当 Item 是一个复杂的组件时,必然会引起性能问题。
上面的流程比较复杂,涉及的也比较多,可以拆开写好几篇文章,有些地方我只是简略的说了一下,如果你不是很明白的话,你可以把上面的例子复制下来,在自己的电脑上调式,我在 Item 的渲染函数中加了打印日志和 debugger,你可以分别用 id 和 index 作为 key 尝试下,你会发现 id 作为 key 时,Item 的渲染函数没有执行,但是 index 作为 key 时,Item 的渲染函数执行了,这就是这两种方式的区别。
延迟渲染就是分批渲染,假设我们某个页面里有一些组件在初始化时需要执行复杂的逻辑:
<template> <p> <!-- Heavy组件初始化时需要执行很复杂的逻辑,执行大量计算 --> <Heavy1 /> <Heavy2 /> <Heavy3 /> <Heavy4 /> </p> </template>
这将会占用很长时间,导致帧数下降、卡顿,其实可以使用分批渲染的方式来进行优化,就是先渲染一部分,再渲染另一部分:
参考黄轶老师揭秘 Vue.js 九个性能优化技巧中的代码:
<template> <p> <Heavy v-if="defer(1)" /> <Heavy v-if="defer(2)" /> <Heavy v-if="defer(3)" /> <Heavy v-if="defer(4)" /> </p> </template> <script> export default { data() { return { displayPriority: 0 } }, mounted() { this.runDisplayPriority() }, methods: { runDisplayPriority() { const step = () => { requestAnimationFrame(() => { this.displayPriority++ if (this.displayPriority < 10) { step() } }) } step() }, defer(priority) { return this.displayPriority >= priority } } } </script>
其实原理很简单,主要是维护displayPriority
变量,通过requestAnimationFrame
在每一帧渲染时自增,然后我们就可以在组件上通过v-if="defer(n)"
使displayPriority
增加到某一值时再渲染,这样就可以避免 js 执行时间过长导致的卡顿问题了。
在 Vue 组件初始化数据时,会递归遍历在 data 中定义的每一条数据,通过Object.defineProperty
将数据改成响应式,这就意味着如果 data 中的数据量很大的话,在初始化时将会使用很长的时间去执行Object.defineProperty
, 也就会带来性能问题,这个时候我们可以强制使数据变为非响应式,从而节省时间,看下这个例子:
<template> <p> <ul> <li v-for="item in heavyData" :key="item.id">{{ item.name }}</li> </ul> </p> </template> <script> // 一万条数据 const heavyData = Array(10000) .fill(null) .map((_, idx) => ({ name: 'test', message: 'test', id: idx })) export default { data() { return { heavyData: heavyData } }, beforeCreate() { this.start = Date.now() }, created() { console.log(Date.now() - this.start) } } </script>
heavyData
中有一万条数据,这里统计了下从beforeCreate
到created
经历的时间,对于这个例子而言,这个时间基本上就是初始化数据的时间。
我在我个人的电脑上多次测试,这个时间一直在40-50ms
,然后我们通过Object.freeze()
方法,将heavyData
变为非响应式的再试下:
//... data() { return { heavyData: Object.freeze(heavyData) } } //...
改完之后再试下,初始化数据的时间变成了0-1ms
,快了有40ms
,这40ms
都是递归遍历heavyData
执行Object.defineProperty
的时间。
那么,为什么Object.freeze()
会有这样的效果呢?对某一对象使用Object.freeze()
后,将不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。
而 Vue 在将数据改造成响应式之前有个判断:
export function observe(value, asRootData) { // ...省略其他逻辑 if ( shouldObserve && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ) { ob = new Observer(value) } // ...省略其他逻辑 }
这个判断条件中有一个Object.isExtensible(value)
,这个方法是判断一个对象是否是可扩展的,由于我们使用了Object.freeze()
,这里肯定就返回了false
,所以就跳过了下面的过程,自然就省了很多时间。
实际上,不止初始化数据时有影响,你可以用上面的例子统计下从created
到mounted
所用的时间,在我的电脑上不使用Object.freeze()
时,这个时间是60-70ms
,使用Object.freeze()
后降到了40-50ms
,这是因为在渲染函数中读取heavyData
中的数据时,会执行到通过Object.defineProperty
定义的getter
方法,Vue 在这里做了一些收集依赖的处理,肯定就会占用一些时间,由于使用了Object.freeze()
后的数据是非响应式的,没有了收集依赖的过程,自然也就节省了性能。
由于访问响应式数据会走到自定义 getter 中并收集依赖,所以平时使用时要避免频繁访问响应式数据,比如在遍历之前先将这个数据存在局部变量中,尤其是在计算属性、渲染函数中使用,关于这一点更具体的说明,你可以看黄奕老师的这篇文章:Local variables
但是这样做也不是没有任何问题的,这样会导致heavyData
下的数据都不是响应式数据,你对这些数据使用computed
、watch
等都不会产生效果,不过通常来说这种大量的数据都是展示用的,如果你有特殊的需求,你可以只对这种数据的某一层使用Object.freeze()
,同时配合使用上文中的延迟渲染、函数式组件等,可以极大提升性能。
Vue 项目不仅可以使用 SFC 的方式开发,也可以使用渲染函数或 JSX 开发,很多人认为仅仅是只是开发方式不同,却不知这些开发方式之间也有性能差异,甚至差异很大,这一节我就找些例子来说明下,希望你以后在选择开发方式时有更多衡量的标准。
其实 Vue2 模板编译中的性能优化不多,Vue3 中有很多,Vue3 通过编译和运行时结合的方式提升了很大的性能,但是由于本篇文章讲的是 Vue2 的性能优化,并且 Vue2 现在还是有很多人在使用,所以我就挑 Vue2 模板编译中的一点来说下。
下面这个模板:
<p>你好! <span>Hello</span></p>
会被编译成:
function render() { with (this) { return _m(0) } }
可以看到和普通的渲染函数是有些不一样的,下面我们来看下为什么会编译成这样的代码。
Vue 的编译会经过optimize
过程,这个过程中会标记静态节点,具体内容可以看黄奕老师写的这个文档:Vue2 编译 - optimize 标记静态节点。
在codegen
阶段判断到静态节点的标记会走到genStatic
的分支:
function genStatic(el, state) { el.staticProcessed = true const originalPreState = state.pre if (el.pre) { state.pre = el.pre } state.staticRenderFns.push(`with(this){return ${genElement(el, state)}}`) state.pre = originalPreState return `_m(${state.staticRenderFns.length - 1}${ el.staticInFor ? ',true' : '' })` }
这里就是生成代码的关键逻辑,这里会把渲染函数保存在staticRenderFns
里,然后拿到当前值的下标生成_m
函数,这就是为什么我们会得到_m(0)
。
这个_m
其实是renderStatic
的缩写:
export function renderStatic(index, isInFor) { const cached = this._staticTrees || (this._staticTrees = []) let tree = cached[index] if (tree && !isInFor) { return tree } tree = cached[index] = this.$options.staticRenderFns[index].call( this._renderProxy, null, this ) markStatic(tree, `__static__${index}`, false) return tree } function markStatic(tree, key) { if (Array.isArray(tree)) { for (let i = 0; i < tree.length; i++) { if (tree[i] && typeof tree[i] !== 'string') { markStaticNode(tree[i], `${key}_${i}`, isOnce) } } } else { markStaticNode(tree, key, isOnce) } } function markStaticNode(node, key, isOnce) { node.isStatic = true node.key = key node.isOnce = isOnce }
renderStatic
的内部实现比较简单,先是获取到组件实例的_staticTrees
,如果没有就创建一个,然后尝试从_staticTrees
上获取之前缓存的节点,获取到的话就直接返回,否则就从staticRenderFns
上获取到对应的渲染函数执行并将结果缓存到_staticTrees
上,这样下次再进入这个函数时就会直接从缓存上返回结果。
拿到节点后还会通过markStatic
将节点打上isStatic
等标记,标记为isStatic
的节点会直接跳过patchVnode
阶段,因为静态节点是不会变的,所以也没必要 patch,跳过 patch 可以节省性能。
通过编译和运行时结合的方式,可以帮助我们很好的提升应用性能,这是渲染函数 / JSX 很难达到的,当然不是说不能用 JSX,相比于模板,JSX 更加灵活,两者有各自的使用场景。在这里写这些是希望能给你提供一些技术选型的标准。
Vue2 的编译优化除了静态节点,还有插槽,createElement 等。
【相关推荐:vue.js教程】
以上是vue有什麼效能最佳化方法的詳細內容。更多資訊請關注PHP中文網其他相關文章!