百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程字典 > 正文

Vue 性能优化之深挖数组

toyiye 2024-06-06 22:11 12 浏览 0 评论

背景

最近在用 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 实例的时候会走到 $mount,即走到上述的 new Watcher,这个就是 renderWatcher,之后走到 updateComponent 函数,也就是会执行 _render,函数内部会通过 vm.$options 取到由 template 编译生成的 render 函数,进而执行 renderWatcher 收集依赖。_render 返回的是组件的 vnode,传入 _update 函数从而执行组件的 patch,最终生成视图。

其次,从我写的 template 来分析,为了渲染选择面板的 DOM,是有两层 for 循环的,内部每次循环都会执行 getItemClass 函数,而函数的内部又是对 questions 这个响应式数组进行了 getter 求值,从目前来看,时间复杂度是 O(n2),如上图所示,我们大概有 2000 多道题目,我们假设有 10 个章节,每个章节有 200 道题目,getItemClass 内部是对 questions 进行了 6 次求值,这样一算,粗略也是 12000 左右,按 js 的执行速度,是不可能这么慢的。

那么问题是不是出现在对 questions 进行 getter 的过程中,出现了 O(n3) 的复杂度呢?

于是,我打开了 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。

export function defineReactive (
 obj: Object,
 key: string,
 val: any,
 customSetter?: ?Function,
 shallow?: boolean
) {
 // 每个属性收集 watcher 的管理器 
 const dep = new Dep()
 ...... 
 // 递归地去将属性值变成响应式 
 let childOb = !shallow && observe(val)
 Object.defineProperty(obj, key, {
 enumerable: true,
 configurable: true,
 get: function reactiveGetter () {
 const value = getter ? getter.call(obj) : val
 if (Dep.target) {
 // 当前属性收集 watcher
 dep.depend() // 语句1
 if (childOb) {
 // 如果当前属性对应的属性值是对象,将当前 watcher 加入 val.__ob__.dep当中去,为什么要这么做呢?先思考一下
 childOb.dep.depend() // 语句2
 // 如果当前属性对应的属性值是数组,递归地将当前 watcher 加入数组每一项,item.__ob__.dep当中去,为什么要这么做呢?
 if (Array.isArray(value)) { // 语句3
 dependArray(value)
 }
 }
 }
 return value
 },
 set: function reactiveSetter (newVal) {
 ..... 
 childOb = !shallow && observe(newVal)
 dep.notify()
 }
 })
}
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)
 }
 }
}

首先,我们从 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 更新。

那么得出结论,语句2的作用是为了能够探测到响应式数据是对象的情况下增删属性而引发重新渲染的。

再举个栗子解释下语句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 条,那么也会执行接近一亿次的语句,性能自然是原地爆炸!

既然从源头分析出了原因,那么就要找出方法从源头上去解决。

  1. 拆分组件
  2. 很多人理解拆分组件是为了复用,当然作用不止是这些,拆分组件更多的是为了可维护性,可以更语义化,在同事看到你的组件名的时候,大概能猜出里面的功能。而我这里拆分组件,是为了隔离无关的响应式数据造成的组件渲染。从上图可以看出,只要任何一个响应式数据改变,Paper 都会重新渲染,比如我点击收藏按钮,Paper 组件会重新渲染,按道理只要收藏按钮这个 DOM 重新渲染即可。
  3. 在嵌套循环中,不要用函数
  4. 性能出现问题的原因是在于我用了 getItemClass 去计算每一个小圆圈的样式,而且在函数里面还对 questions 进行了求值,这样时间复杂度从 O(n2) 变成了 O(n3)(由于源码的 dependArray也会循环)。最后的解决方案,我是弃用了 getItemClass 这个函数,直接更改了 cardData 的 tids 的数据结构,变成了 tInfo,也就是在构造数据的时候,计算好样式。
this.cardData = [{
 startIndex: 0,
 secName: '章节名称',
 secId: '章节id',
 tInfo: [
 {
 id: 1,
 klass: 'item_false'
 }, 
 {
 id: 2,
 klass: 'item_false_active'
 }]
}]
  1. 如此一来,就不会出现 O(n3) 时间复杂度的问题了。
  2. 善用缓存
  3. 我发现 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。如果自学吃力的情况下,可以考虑配套视频,毕竟用知识武装自己,在 IT 界永不吃亏。
  • 实现一个需求很容易,但是要把性能做到最佳,成本可能急剧增加。

希望本文能帮助到您!

点赞+转发,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓-_-)

关注 {我},享受文章首发体验!

每周重点攻克一个前端技术难点。更多精彩前端内容私信 我 回复“教程”

原文链接:https://github.com/DDFE/DDFE-blog/issues/35

作者:稽智

相关推荐

为何越来越多的编程语言使用JSON(为什么编程)

JSON是JavascriptObjectNotation的缩写,意思是Javascript对象表示法,是一种易于人类阅读和对编程友好的文本数据传递方法,是JavaScript语言规范定义的一个子...

何时在数据库中使用 JSON(数据库用json格式存储)

在本文中,您将了解何时应考虑将JSON数据类型添加到表中以及何时应避免使用它们。每天?分享?最新?软件?开发?,Devops,敏捷?,测试?以及?项目?管理?最新?,最热门?的?文章?,每天?花?...

MySQL 从零开始:05 数据类型(mysql数据类型有哪些,并举例)

前面的讲解中已经接触到了表的创建,表的创建是对字段的声明,比如:上述语句声明了字段的名称、类型、所占空间、默认值和是否可以为空等信息。其中的int、varchar、char和decimal都...

JSON对象花样进阶(json格式对象)

一、引言在现代Web开发中,JSON(JavaScriptObjectNotation)已经成为数据交换的标准格式。无论是从前端向后端发送数据,还是从后端接收数据,JSON都是不可或缺的一部分。...

深入理解 JSON 和 Form-data(json和formdata提交区别)

在讨论现代网络开发与API设计的语境下,理解客户端和服务器间如何有效且可靠地交换数据变得尤为关键。这里,特别值得关注的是两种主流数据格式:...

JSON 语法(json 语法 priority)

JSON语法是JavaScript语法的子集。JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔花括号保存对象方括号保存数组JS...

JSON语法详解(json的语法规则)

JSON语法规则JSON语法是JavaScript对象表示法语法的子集。数据在名称/值对中数据由逗号分隔大括号保存对象中括号保存数组注意:json的key是字符串,且必须是双引号,不能是单引号...

MySQL JSON数据类型操作(mysql的json)

概述mysql自5.7.8版本开始,就支持了json结构的数据存储和查询,这表明了mysql也在不断的学习和增加nosql数据库的有点。但mysql毕竟是关系型数据库,在处理json这种非结构化的数据...

JSON的数据模式(json数据格式示例)

像XML模式一样,JSON数据格式也有Schema,这是一个基于JSON格式的规范。JSON模式也以JSON格式编写。它用于验证JSON数据。JSON模式示例以下代码显示了基本的JSON模式。{"...

前端学习——JSON格式详解(后端json格式)

JSON(JavaScriptObjectNotation)是一种轻量级的数据交换格式。易于人阅读和编写。同时也易于机器解析和生成。它基于JavaScriptProgrammingLa...

什么是 JSON:详解 JSON 及其优势(什么叫json)

现在程序员还有谁不知道JSON吗?无论对于前端还是后端,JSON都是一种常见的数据格式。那么JSON到底是什么呢?JSON的定义...

PostgreSQL JSON 类型:处理结构化数据

PostgreSQL提供JSON类型,以存储结构化数据。JSON是一种开放的数据格式,可用于存储各种类型的值。什么是JSON类型?JSON类型表示JSON(JavaScriptO...

JavaScript:JSON、三种包装类(javascript 包)

JOSN:我们希望可以将一个对象在不同的语言中进行传递,以达到通信的目的,最佳方式就是将一个对象转换为字符串的形式JSON(JavaScriptObjectNotation)-JS的对象表示法...

Python数据分析 只要1分钟 教你玩转JSON 全程干货

Json简介:Json,全名JavaScriptObjectNotation,JSON(JavaScriptObjectNotation(记号、标记))是一种轻量级的数据交换格式。它基于J...

比较一下JSON与XML两种数据格式?(json和xml哪个好)

JSON(JavaScriptObjectNotation)和XML(eXtensibleMarkupLanguage)是在日常开发中比较常用的两种数据格式,它们主要的作用就是用来进行数据的传...

取消回复欢迎 发表评论:

请填写验证码