这篇文章主要为大家详细介绍了浅谈Vue 性能优化之深挖数组,具有一定的参考价值,可以用来参考一下。
感兴趣的小伙伴,下面一起跟随四海网的小编两巴掌来看看吧!
最近在用 Vue 重构一个历史项目,一个考试系统,题目量很大,所以核心组件的性能成为了关注点。先来两张图看下最核心的组件 Paper 的样式。
【图片暂缺】
【图片暂缺】
从图中来看,分为答题区与选择面板区。
稍微对交互逻辑进行下拆解:
基于以上考虑,我觉得我必须有三个响应式的数据:
currentIndex
: 当前选中题目的序号。questions
:所有题目的信息,是个数组,里面维护了每道题的问题、选项、正确与否等信息。cardData
:题目分组的信息,也是个数组,按章节名称对不同的题目进行了分类。数组每一项数据结构如下:
代码如下:
currentIndex = 0 // 用来标记当前选中题目的索引
questions = [{
secId: 1, // 所属章节的 id
tid: 1, // 题目 id
content: '题目内容' // 题目描述
type: 1, // 题型,1 ~ 3 (单选,多选,判断)
options: ['选项1', '选项2', '选项3', '选项4',] // 每个选项的描述
choose: [1, 2, 4], // 多选——记录用户未提交前的选项
done: true, // 标记当前题目是否已做
answerIsTrue: undefined // 标记当前题目的正确与否
}]
cardData = [{
startIndex: 0, // 用来记录循环该分组数据的起始索引,这个值等于前面数据的长度累加。
secName: '章节名称',
secId: '章节id',
tids: [1, 2, 3, 11] // 该章节下面的所有题目的 id
}]
由于题目可以左右滑动切换,所以我每次从 questions
取了三个数据去渲染,用的是 cube-ui 的 Slide 组件,只要自己根据 this.currentIndex 结合 computed 特性去动态的切割三个数据就行。
这一切都显得很美好,尤其是即将结束了一个历史项目的核心组件的编写之前,心情特别的舒畅。
代码逻辑很简单,但是发生了让我懵逼的事情。
代码如下:
<div class="card-content">
<div class="block" v-for="item in cardData" :key="item.secName">
<div class="sub-title">{{item.secName}}</div>
<div class="group">
<span
@click="cardClick(index + item.startIndex)"
class="item"
:class="getItemClass(index + item.startIndex)"
v-for="(subItem, index) in item.secTids"
:key="subItem">{{index + item.startIndex + 1}}</span>
</div>
</div>
</div>
其实就是利用 cardData 去生成 DOM 元素,这是个分组数据(先是以章节为维度,章节下面还有对应的题目),上面的代码其实是一个循环里面嵌套了另一个循环。
当下的第一反应,肯定是 js 在某一步的执行时间过长,所以利用 Chrome 自带的 Performance 工具 追踪了一下,发现问题出在 getItemClass
这个函数调用,占据了 99% 的时间,而且时间都超过 1s 了。瞅了眼自己的代码:
代码如下:
getItemClass (index) {
const ret = {}
// 如果是做对的题目,但并不是当前选中
ret['item_true'] = this.questions[index]......
// 如果是做对的题目,并且是当前选中
ret['item_true_active'] = this.questions[index]......
// 如果是做错的题目,但并不是当前选中
ret['item_false'] = this.questions[index]......
// 如果是做错的题目,并且是当前选中
ret['item_false_active'] = this.questions[index]......
// 如果是未做的题目,但不是当前选中
ret['item_undo'] = this.questions[index]......
// 如果是未做的题目,并且是当前选中
ret['item_undo_active'] = this.questions[index]......
return ret
},
这个函数主要是用来计算选择面板每一个小圆圈该有的样式。每一步都是对 questions 进行了 getter 操作。初看,好像没什么问题,但是由于之前看过 Vue 的源码,细想之下,觉得不对。
首先,webpack 会将 .vue 文件的 template 转换成 render 函数,也就是实例化组件的时候,其实是对响应式属性求值的过程,这样响应式属性就能将 renderWatcher 加入依赖当中,所以当响应式属性改变的时候,能触发组件重新渲染。
我们先来了解下 renderWatcher 是什么概念,首先在 Vue 的源码里面是有三种 watcher 的。我们只看 renderWatcher 的定义。
代码如下:
// 位于 vue/src/core/instance/lifecycle.js
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 位于 vue/src/core/instance/render.js
Vue.prototype._render = function (): VNode {
......
const { render, _parentVnode } = vm.$options
try {
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
......
}
return vnode
}
稍微分析下流程:实例化 Vue 实例的时候会走到 options 取到由 template 编译生成的 render 函数,进而执行 renderWatcher 收集依赖。_render 返回的是组件的 vnode,传入 _update 函数从而执行组件的 patch,最终生成视图。
其次,从我写的 template 来分析,为了渲染选择面板的 DOM,是有两层 for 循环的,内部每次循环都会执行 getItemClass 函数,而函数的内部又是对 questions 这个响应式数组进行了 getter 求值,从目前来看,时间复杂度是 O(n²),如上图所示,我们大概有 2000 多道题目,我们假设有 10 个章节,每个章节有 200 道题目,getItemClass 内部是对 questions 进行了 6 次求值,这样一算,粗略也是 12000 左右,按 js 的执行速度,是不可能这么慢的。
于是,我打开了 Vue 的源码,由于之前深入研究过源码,所以轻车熟路地找到了 vue/src/core/instance/state.js
里面将 data 转换成 getter/setter 的部分。
代码如下:
function initData (vm: Component) {
......
// observe data
observe(data, true /* asRootData */)
}
定义一个组件的 data 的响应式,都是从 observe 函数开始,它的定义是位于 vue/src/core/observer/index.js
。
代码如下:
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) {
return
}
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
observe 函数接受对象或者数组,内部会实例化 Observer 类。
代码如下:
export class Observer {
value: any;
dep: Dep;
vmCount: number;
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
Observer 的构造函数很简单,就是声明了 dep、value 属性,并且将 value 的 _ ob _ 属性指向当前实例。举个栗子:
代码如下:
// 刚开始的 options
export default {
data : {
msg: '消息',
arr: [1],
item: {
text: '文本'
}
}
}
// 实例化 vm 的时候,变成了以下
data: {
msg: '消息',
arr: [1, __ob__: {
value: ...,
dep: new Dep(),
vmCount: ...
}],
item: {
text: '文本',
__ob__: {
value: ...,
dep: new Dep(),
vmCount: ...
}
},
__ob__: {
value: ...,
dep: new Dep(),
vmCount: ...
}
}
也就是每个对象或者数组被 observe 之后,多了一个 _ ob _ 属性,它是 Observer 的实例。那么这么做的意义何在呢,稍后分析。
继续分析 Observer 构造函数的下面部分:
代码如下:
// 如果是数组,先篡改数组的一些方法(push,splice,shift等等),使其能够支持响应式
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
// 数组里面的元素还是数组或者对象,递归地调用 observe 函数,使其成为响应式数据
this.observeArray(value)
} else {
// 遍历对象,使其每个键值也能成为响应式数据
this.walk(value)
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
// 将对象的键值转换成 getter / setter,
// getter 收集依赖
// setter 通知 watcher 更新
defineReactive(obj, keys[i])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
我们再捋一下思路,首先在 initState 里面调用 initData,initData 得到用户配置的 data 对象后调用了 observe,observe 函数里面会实例化 Observer 类,在其构造函数里面,首先将对象的 _ ob _ 属性指向 Observer 实例(这一步是为了检测到对象添加或者删除属性之后,能触发响应式的伏笔),之后遍历当前对象的键值,调用 defineReactive 去转换成 getter / setter。
所以,来分析下 defineReactive。
代码如下:
// 如果是数组,先篡改数组的一些方法(push,splice,shift等等),使其能够支持响应式
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
// 数组里面的元素还是数组或者对象,递归地调用 observe 函数,使其成为响应式数据
this.observeArray(value)
} else {
// 遍历对象,使其每个键值也能成为响应式数据
this.walk(value)
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
// 将对象的键值转换成 getter / setter,
// getter 收集依赖
// setter 通知 watcher 更新
defineReactive(obj, keys[i])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
首先,我们从 defineReactive 可以看出,每个响应式属性都有一个 Dep 实例,这个是用来收集 watcher 的。由于 getter 与 setter 都是函数,并且引用了 dep,所以形成了闭包,dep 一直存在于内存当中。因此,假如在渲染组件的时候,如果使用了响应式属性 a,就会走到上述的语句1,dep 实例就会收集组件这个 renderWatcher,因为在对 a 进行 setter 赋值操作的时候,会调用 dep.notify() 去 通知 renderWatcher 去更新,进而触发响应式数据收集新一轮的 watcher。
那么语句2与3,到底是什么作用呢
我们举个栗子分析
代码如下:
<div>{{person}}<div>
代码如下:
export default {
data () {
return {
person: {
name: '张三',
age: 18
}
}
}
}
this.person.gender = '男' // 组件视图不会更新
因为 Vue 是无法探测到对象增添属性,所以也没有一个时机去触发 renderWatcher 的更新。
为此, Vue 提供了一个 API, this.$set
,它是 Vue.set
的别名。
代码如下:
export function set (target: Array<any> | Object, key: any, val: any): any {
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
set 函数接受三个参数,第一个参数可以是 Object 或者 Array,其余的参数分别为 key, value。如果利用这个 API 给 person 增加一个属性呢?
代码如下:
this.$set(this.person, 'gender', '男') // 组件视图重新渲染
为什么通过 set 函数又能触发重新渲染呢?注意到这一句, ob.dep.notify()
, ob
怎么来的呢,那就得回到之前的 observe 函数了,其实 data 经过 observe 处理之后变成下面这样。
代码如下:
{
person: {
name: '张三',
age: 18,
__ob__: {
value: ...,
dep: new Dep()
}
},
__ob__: {
value: ...,
dep: new Dep()
}
}
// 只要是对象,都定义了 __ob__ 属性,它是 Observer 类的实例
从 template 来看,视图依赖了 person 这个属性值,renderWatcher 被收集到了 person 属性的 Dep 实例当中,对应 defineReactive
函数定义的 语句1 ,同时, 语句2 的作用就是将 renderWatcher 收集到 person._ ob _.dep 当中去,因此在给 person 增加属性的时候,调用 set 方法才能获取到 person._ ob _.dep,进而触发 renderWatcher 更新。
再举个栗子解释下 语句3 的作用。
代码如下:
<div>{{books}}<div>
代码如下:
export default {
data () {
return {
books: [
{
id: 1,
name: 'js'
}
]
}
}
}
因为组件对 books 进行求值,而它是一个数组,所以会走到语句3的逻辑。
代码如下:
if (Array.isArray(value)) { // 语句3
dependArray(value)
}
function dependArray (value: Array<any>) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend()
if (Array.isArray(e)) {
dependArray(e)
}
}
}
从逻辑上来看,就是循环 books 的每一项 item,如果 item 是一个数组或者对象,就会获取到 item._ ob _.dep,并且将当前 renderWatcher 收集到 dep 当中去。
如果没有这一句,会发生什么情况?考虑下如下的情况:
代码如下:
this.$set(this.books[0], 'comment', '棒极了') // 并不会触发组件更新
如果理解成 renderWatch 并没有对 this.books[0] 进行求值,所以改变它并不需要造成组件更新,那么这个理解是有误的。正确的是因为数组是元素的集合,内部的任何修改是需要反映出来的,所以语句3就是为了在 renderWatcher 对数组求值的时候,将 renderWatcher 收集到数组内部每一项 item._ ob _.dep 当中去,这样只要内部发生变化,就能通过 dep 获取到 renderWatcher,通知它更新。
那么结合我的业务代码,就分析出来问题出现在语句3当中。
代码如下:
<div class="card-content">
<div class="block" v-for="item in cardData" :key="item.secName">
<div class="sub-title">{{item.secName}}</div>
<div class="group">
<span
@click="cardClick(index + item.startIndex)"
class="item"
:class="getItemClass(index + item.startIndex)"
v-for="(subItem, index) in item.secTids"
:key="subItem">{{index + item.startIndex + 1}}</span>
</div>
</div>
</div>
代码如下:
getItemClass (index) {
const ret = {}
// 如果是做对的题目,但并不是当前选中
ret['item_true'] = this.questions[index]......
// 如果是做对的题目,并且是当前选中
ret['item_true_active'] = this.questions[index]......
// 如果是做错的题目,但并不是当前选中
ret['item_false'] = this.questions[index]......
// 如果是做错的题目,并且是当前选中
ret['item_false_active'] = this.questions[index]......
// 如果是未做的题目,但不是当前选中
ret['item_undo'] = this.questions[index]......
// 如果是未做的题目,并且是当前选中
ret['item_undo_active'] = this.questions[index]......
return ret
},
首先 cardData
是一个分组数据,循环里面套循环,假设有 10 个章节, 每个章节有 200 道题目,那么其实会执行 2000 次 getItemClass 函数,getItemClass 内部会有 6 次对 questions 进行求值,每次都会走到 dependArray,每次执行 dependArray 都会循环 2000 次,所以粗略估计 2000 * 6 * 2000 = 2400 万次,如果假设一次执行的语句是 4 条,那么也会执行接近一亿次的语句,性能自然是原地爆炸!
很多人理解拆分组件是为了复用,当然作用不止是这些,拆分组件更多的是为了可维护性,可以更语义化,在同事看到你的组件名的时候,大概能猜出里面的功能。而我这里拆分组件,是为了隔离无关的响应式数据造成的组件渲染。从上图可以看出,只要任何一个响应式数据改变,Paper 都会重新渲染,比如我点击收藏按钮,Paper 组件会重新渲染,按道理只要收藏按钮这个 DOM 重新渲染即可。
性能出现问题的原因是在于我用了 getItemClass 去计算每一个小圆圈的样式,而且在函数里面还对 questions 进行了求值,这样时间复杂度从 O(n²) 变成了 O(n³)(由于源码的 dependArray也会循环)。最后的解决方案,我是弃用了 getItemClass 这个函数,直接更改了 cardData 的 tids 的数据结构,变成了 tInfo,也就是在构造数据的时候,计算好样式。
代码如下:
this.cardData = [{
startIndex: 0,
secName: '章节名称',
secId: '章节id',
tInfo: [
{
id: 1,
klass: 'item_false'
},
{
id: 2,
klass: 'item_false_active'
}]
}]
如此一来,就不会出现 O(n³) 时间复杂度的问题了。
我发现 getItemClass 里面自己写的很不好,其实应该用个变量去缓存 quesions,这样就不会造成对 questions 多次求值,进而多次走到源码的 dependArray 当中去。
代码如下:
const questions = this.questions
// good // bad
// questions[0] this.questions[0]
// questions[1] this.questions[1]
// questions[2] this.questions[2]
......
// 前者只会对 this.questions 一次求值,后者会三次求值
从这次教训,自己也学到了也很多。
遇到问题的时候,要利用现有工具去分析问题的原因,比如 Chrome 自带的 Performance。
对于自己所用的技术,要追根究底,庆幸自己之前深入研究过 Vue 的源码,这样才能游刃有余地去解决问题,否则现在估计还一头雾水,如果有想深入理解 Vue 的小伙伴,可以参考Vue.js 技术揭秘,看过 GitHub 上面很多源码分析,这个应该是写的最全最好的,我自己也对该源码分析提过 PR。
实现一个需求很容易,但是要把性能做到最佳,成本可能急剧增加。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持四海网。
本文来自:http://www.q1010.com/184/7128-0.html
注:关于浅谈Vue 性能优化之深挖数组的内容就先介绍到这里,更多相关文章的可以留意四海网的其他信息。
关键词:vue.js
四海网收集整理一些常用的php代码,JS代码,数据库mysql等技术文章。