🚩Vue源码——收集订阅者引起的性能问题

2,825 阅读3分钟

前言

这篇专栏中详细介绍了订阅者的收集过程,但是漏掉当发布者的值是对象或数组时是如何收集订阅者的介绍,其收集过程可能会引发性能问题。当然这不是 Vue 本身的问题。

一、回顾订阅者收集的核心过程

这篇专栏中介绍过,当读取数据时会触发 getter 函数,在 getter 函数中收集订阅者。而 getter 函数是在 defineReactive 函数中定义。

function defineReactive(obj, key, val, customSetter, shallow) {
    var dep = new Dep();
    var getter = property && property.get;
    var setter = property && property.set;
    if ((!getter || setter) && arguments.length === 2) {
        val = obj[key];
    }
    var childOb = !shallow && observe(val);
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            var value = getter ? getter.call(obj) : val;
            if (Dep.target) {
                dep.depend();
                if (childOb) {
                    childOb.dep.depend();
                    if (Array.isArray(value)) {
                        dependArray(value);
                    }
                }
            }
            return value
        },
        set: function reactiveSetter(newVal) {
        }
    });
}

defineReactive 函数中。实例化 Dep 类并把实例化对象赋值给常量 dep。执行 var childOb = !shallow && observe(val),如果被监听的数据的值是个对象或数据,执行 observe(val) 继续监听,并把返回值赋值给常量 childOb

getter 函数定义在 defineReactive 函数中,且在里面引用常量 dep 和常量 childOb ,这样就构成了一个闭包。常量 dep 和常量 childOb 一直存在内存中,后面读取发布者时触发 getter 函数,其 depchildOb 都可以使用。

这里先介绍一下childObdep 是在哪里赋值的?这要到 observe 函数中去寻找答案。

function observe(value, asRootData) {
    if (!isObject(value) || value instanceof VNode) {
        return
    }
    var ob;
    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 函数返回一个常量 ob ,第一次监听时,常量 ob 是用 new Observer(value) 生成一个实例化对象来赋值的,故来看一下 Observer 这个构造函数。

var Observer = function Observer(value) {
    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);
    }
}

在里面执行 this.dep = new Dep() 赋值给 this.dep,所以 ob 上的 dep ,也就是 childOb 上的 dep,是一个 Dep 类实例化对象。

当发布者被读取时,会触发 getter 函数,执行 dep.depend() 触发发布者自身收集订阅者,如果发布者的值是对象或数组,那么 childOb 存在,执行 childOb.dep.depend() 触发发布者的值自身收集订阅者。

执行 if (Array.isArray(value)) 判断发布者的值如果是数组的话,则要执行 dependArray(value),来看一下 dependArray 函数。

function dependArray(value) {
    for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
      e = value[i];
      e && e.__ob__ && e.__ob__.dep.depend();
      if (Array.isArray(e)) {
          dependArray(e);
      }
    }
}

执行 value[i] 获取数组子项赋值给变量 e,执行 e && e.__ob__ && e.__ob__.dep.depend(),判断数组子项有订阅者收集器 dep 情况下执行 e.__ob__.dep.depend() 触发数组子项自身收集订阅者。

可以发现不管是 childOb.dep.depend() 还是 dependArray(value) 都是触发自身收集订阅者。

这是因为对象类型数据的属性的增删和数组类型数据的子项的增删,在 Vue 中是监听不到的,Vue 给出 vm.$set 实例方法来解决,具体实现原理可以看这篇专栏

vm.$set 实例方法,最后会执行 ob.dep.notify(),通知对象或数组类型数据自身收集的订阅者进行响应更新。

所以在要在发布者的 getter 函数中,如果发布者的值是对象或数组要收集一下值自身的订阅者。又因为数组的子项可能是对象或数组,所以要递归调用 dependArray 函数,触发数组中的是对象或数组的子项进行订阅者的收集。

二、收集过程如何引起性能问题

其引起性能问题的关键地方在于 dependArray(value) 的执行。是发生在 v-for 循环中读取数组类型的数据(发布者)引起的。这里以一个实际的业务场景来说明。

在一个考试模拟功能中,题库后端接口提供,是个数组集合,有3000多道题目,每次随机抽取100个题目组成一套试卷。

定义 questions 来存储后端返回的题库,是个数组集合,数据结构如下图所示

questions: [{
    tid: 1, // 题目 id
    content: 'xxxx' ,// 题目描述
    options: ['选项1', '选项2', '选项3', '选项4'], // 每个选项的描述
}]

定义 questionIds 来存储在 0 到 2999 中随机获取的 100 个整数,把这些数字作为数组的下标去 questions 中获取对应的题目。

把一份试卷的内容在页面展示出来。实现代码如下:

<template>
    <div>
    	<div v-for="item in questionIds">
            <div class="p-question-content">{{questions[item].content}}</div>
            <div class="p-question-options">
                <span v-for = "a in questions[item].options">{{a}}</span>
            </div>
        </div>
    </div>
</template>
<script>
export default {
    data(){
        return {
            questionIds: [0,1,4,5 ... 100],
            questions: [
                {
                    tid: 1, // 题目 id
                    content: 'xxxx', // 题目描述
                    options: ['选项1','选项2','选项3','选项4'], // 每个选项的描述
                },
                {
                    tid: 2, // 题目 id
                    content: 'xxxx', // 题目描述
                    options: ['选项1','选项2', '选项3','选项4'], // 每个选项的描述
                },
                //...
            ],
        }
    }
}
</script>

以上代码看上去很正常,页面也能正常展示。先来算一下渲染过程中的循环次数,100个题目,每个题目的选项暂定都是4个选项的,那么应该只有400次循环。

真的只有 400 次循环吗?其实内部经历最少 3000 * 5 * 100 共 1500000 次循环。是不是有点夸张,在哪里会产生这么多次循环。就是在 getter 函数中收集订阅者产生的循环,来看一下 getter 函数中收集订阅者的逻辑:

if (Dep.target) {
    dep.depend();
    if (childOb) {
        childOb.dep.depend();
        if (Array.isArray(value)) {
            dependArray(value);
        }
    }
}

在模板中访问到 questions 时,触发以上逻辑执行:

  • 在此场景中其 Dep.target 为渲染 Watcher(渲染订阅者),执行 dep.depend() 触发 questions 自身收集渲染订阅者。
  • 因为 questions 的值是个数组,故 childOb 有值,执行 childOb.dep.depend() 触发 questions 的值自身收集渲染订阅者。
  • 因为 questions 的值是个数组,故执行 if (Array.isArray(value)) 条件满足,则执行 dependArray(value)

那么多次的循环就是在 dependArray 函数中产生的,再来看一下 dependArray 函数。

function dependArray(value) {
    for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
      e = value[i];
      e && e.__ob__ && e.__ob__.dep.depend();
      if (Array.isArray(e)) {
          dependArray(e);
      }
    }
}

可以看出在 dependArray 函数会循环 questions 的值,如果 questions 的值的子项也是个数组,再次调用 dependArray 函数。

questions 的值是题库的集合,数量为3000,那么执行一次 dependArray(value) 至少会循环 3000 次。

模板中 questions 在一个 v-for="item in questionIds" 循环中读取了 5 次,也就是至少会循环了 15000 次。questionIds 数组的长度是 100 ,那么把一份试卷渲染出来至少要循环150万次。

从表面的400次循环,内部增加到150万次,会引起很大的性能问题。

三、如何解决这个性能问题

要解决这个性能问题很简单。不在模板的 v-for 循环中使用数组类型的数据就行,如果一定要使用的话,先用Object.freeze() 把这个数组类型的数据冻结了再使用。实现代码如下:

<template>
    <div>
    	<div v-for="item in questionIds">
            <div class="p-question-content">{{questions[item].content}}</div>
            <div class="p-question-options">
                <span v-for = "a in questions[item].options">{{a}}</span>
            </div>
        </div>
    </div>
</template>
<script>
export default {
    data(){
        return {
            questionIds: [0,1,4,5 ... 100],
            questions: [],
        }
    },
    mounted(){
        const arr = [
            {
                tid: 1, // 题目 id
                content: 'xxxx', // 题目描述
                options: ['选项1','选项2','选项3','选项4'], // 每个选项的描述
            },
            {
                tid: 2, // 题目 id
                content: 'xxxx', // 题目描述
                options: ['选项1','选项2', '选项3','选项4'], // 每个选项的描述
            },
        ]
        this.questions = Object.freeze(arr)
    }
}
</script>