Массив глубокого копания для оптимизации производительности Vue

Vue.js

Автор: Цзи Чжи

задний план

Недавно я рефакторил исторический проект и систему экзаменов с Vue, Количество вопросов очень велико, поэтому производительность основных компонентов стала в центре внимания. Давайте взглянем на стиль основного компонента Бумага с двумя картинками.

На рисунке она разделена на область ответов и область панели выбора.

Небольшая разборка логики взаимодействия:

  • Режим ответа и режим обучения можно переключать друг на друга для управления отображением и сокрытием правильного ответа.
  • Для вопросов с одним вариантом ответа и вопросов «верно-неверно» щелкните непосредственно, чтобы записать правильность ответа, а для вопросов с несколькими вариантами ответов нажмите «ОК», чтобы записать правильность ответа.
  • Панель выбора предназначена для записи статуса выполненных вопросов, который разделен на шесть состояний (не выполнено, не выполнено и выбрано в данный момент, сделано неправильно, сделано неправильно и выбрано в данный момент, выполнено правильно, выполнено правильно и выбрано в данный момент) , используйте разные стили, чтобы различать.
  • Щелкните панель выбора, и область ответа может переключиться на соответствующий номер вопроса.

Основываясь на приведенных выше соображениях, я думаю, что у меня должно быть три отзывчивых данных:

  • 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 в сочетании с вычисляемой функцией.

Это все кажется очень красивым, особенно перед окончанием написания основных компонентов исторического проекта, настроение особенно комфортное.

Однако переломный момент наступил на этапе рендеринга стиля панели выбора.

Логика кода очень проста, но случилось то, от чего у меня закружилась голова.

<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, чтобы отследить его, и обнаружил, что проблема заключается вgetItemClassЭтот вызов функции занимает 99% времени, а время больше 1с. Взгляните на мой код:

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
},

Эта функция в основном используется для расчета стиля каждого маленького круга на панели выбора. Каждый шаг — это геттерная операция над вопросами. На первый взгляд кажется, что проблем нет, но, поскольку я уже видел исходный код Vue, я думаю, что это неправильно.

Во-первых, webpack преобразует шаблон файла .vue в функцию рендеринга, то есть при создании экземпляра компонента фактически происходит процесс оценки атрибута Response, чтобы атрибут Response мог добавить renderWatcher в зависимость, поэтому, когда используется адаптивный атрибут. Когда свойство изменяется, это может вызвать повторную визуализацию компонента.

Давайте сначала разберемся, что такое концепция renderWatcher Прежде всего, в исходном коде Vue есть три наблюдателя. Мы просто смотрим на определение 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 принимает функцию рендеринга, сгенерированную шаблоном, для выполнения зависимости от коллекции renderwatcher. _Render возвращает vNode компонента, получая функцию _UPDATE для выполнения исправления компонента и, наконец, создания представления.

Во-вторых, из анализа шаблона, который я написал, для визуализации DOM панели выбора есть два слоя циклов for.Функция getItemClass выполняется каждый раз, когда выполняется внутренний цикл, а внутренняя часть функции геттер для ответного массива вопросов.С текущей точки зрения, временная сложность O(n²).Как показано на рисунке выше, у нас около 2000 вопросов.Мы предполагаем, что есть 10 глав, и каждая глава имеет Вопросов 200. Требуется 6 оценок, и этот расчет составляет примерно около 12000. Судя по скорости выполнения js, невозможно быть таким медленным.

Значит, проблема возникает в процессе получения вопросов и появляется сложность O(n³)?

Итак, я открыл исходный код Vue, так как ранее я подробно изучал исходный код, я нашел его с легкостью.vue/src/core/instance/state.jsЧасть, которая преобразует данные в геттер/сеттер.

function initData (vm: Component) {
  ......
  // observe data
  observe(data, true /* asRootData */)
}

Оперативность определения данных компонента начинается с функции наблюдения, которая определена в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
}

Функция наблюдения принимает объект или массив, а внутри создается экземпляр класса 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 и устанавливает _ значения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: ...
    }
}

То есть после каждого наблюдения объекта или массива есть еще один _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])
    }
}

Давайте подумаем об этом еще раз. Во-первых, initData вызывается в initState. После того, как initData получает объект данных, настроенный пользователем, вызывается наблюдать. В функции наблюдения будет создан экземпляр класса Observer. В ее конструкторе объект _obАтрибут _ указывает на экземпляр Observer (этот шаг должен вызвать ответное предзнаменование после обнаружения добавления или удаления атрибутов объекта), затем пройти значение ключа текущего объекта и вызвать defineReactive, чтобы преобразовать его в геттер/ сеттер

Итак, давайте проанализируем 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, который используется для сбора наблюдателей. Поскольку и геттеры, и сеттеры являются функциями и ссылаются на dep, формируется замыкание, и dep всегда существует в памяти. Поэтому, если вы используете адаптивный атрибут a при рендеринге компонента, вы перейдете к приведенному выше оператору 1, и экземпляр dep соберет renderWatcher компонента, потому что, когда операция присваивания setter выполняется для a, dep.notify( ), чтобы уведомить renderWatcher об обновлении, что, в свою очередь, запускает новый раунд наблюдения для оперативного сбора данных.

Итак, каковы функции предложений 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, а остальные параметры являются ключом и значением соответственно. Что, если вы используете этот API для добавления свойства к человеку?

this.$set(this.person, 'gender', '男') // 组件视图重新渲染

Почему повторный рендеринг может запускаться функцией set? Обратите внимание на это предложение,ob.dep.notify(),obПочему же тогда вам нужно вернуться к предыдущей функции наблюдения.По сути, после обработки данных наблюдения, она становится следующей.

{
  person: {
    name: '张三',
    age: 18,
    __ob__: {
      value: ...,
      dep: new Dep()
    }
  },
  __ob__: {
    value: ...,
    dep: new Dep()
  }
}
// 只要是对象,都定义了 __ob__ 属性,它是 Observer 类的实例

С точки зрения шаблона представление зависит от значения свойства person, а renderWatcher собирается в экземпляр Dep свойства person, соответствующийdefineReactiveфункция определенаЗаявление 1,в то же время,Заявление 2Роль состоит в том, чтобы собирать renderWatcher для человека._ob_.dep, поэтому при добавлении атрибутов к человеку вызовите метод set, чтобы получить человека._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)
    }
  }
}

Логически, это цикл по каждому элементу книг. Если элемент является массивом или объектом, item._ob_.dep и собрать текущий renderWatcher в dep.

Что было бы без этой фразы? Рассмотрим следующую ситуацию:

this.$set(this.books[0], 'comment', '棒极了') // 并不会触发组件更新

Если понимать, что renderWatch не оценивает this.books[0], поэтому его изменение не должно вызывать обновление компонента, то такое понимание неверно. Правильно то, что, поскольку массив представляет собой набор элементов, любые внутренние изменения должны быть отражены, поэтому оператор 3 должен собирать renderWatcher в каждый элемент в массиве, когда renderWatcher оценивает массив._ob_.dep, так что до тех пор, пока внутренние изменения, вы можете получить renderWatcher через dep и уведомить его об обновлении.

Затем в сочетании с моим бизнес-кодом анализируется, что проблема появляется в утверждении 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 вопросов. Фактически функция getItemClass будет выполняться 2000 раз. Будет 6 раз, чтобы оценить вопросы внутри getItemClass, и каждый раз, когда он будет переходить к dependArray, каждый раз, когда будет выполняться dependArray, он будет повторяться 2000 раз, поэтому грубая оценка будет 2000 * 6 * 2000 = 24 миллиона раз, если предположить, что есть 4 оператора, выполняемые в раз, то операторы будут выполняться почти 100 миллионов раз, и производительность, естественно, на месте взорвется!

Поскольку причина была проанализирована из источника, необходимо найти способ ее решения из источника.

  1. Разделить компоненты

    Многие люди понимают, что разделение компонентов предназначено для повторного использования. Конечно, цель разделения компонентов заключается не только в этом. Разделение компонентов больше для удобства сопровождения и может быть более семантическим. Когда коллеги видят название вашего компонента, они, вероятно, могут догадаться о внутренней функции. . И я разделил компоненты здесь, чтобы изолировать рендеринг компонентов, вызванный нерелевантными ответными данными. Как видно из приведенного выше рисунка, пока какие-либо реагирующие данные изменяются, документ будет повторно визуализирован. Например, если я нажму кнопку «Избранное», компонент «Бумага» будет повторно визуализирован. - визуализировать DOM кнопки «Избранное».

  2. Во вложенных циклах не используйте функции

    Причина проблемы с производительностью заключается в том, что я использовал getItemClass для расчета стиля каждого маленького круга, а также оценивал вопросы в функции, поэтому временная сложность изменилась с O(n²) на O(n³) (из-за зависимостей исходного массива также петли). Окончательное решение, я отказался от функции getItemClass, и напрямую изменил структуру данных tids от cardData на tInfo, то есть при построении данных вычисляю стиль.

    this.cardData = [{
        startIndex: 0,
        secName: '章节名称',
        secId: '章节id',
        tInfo: [
        {
            id: 1,
            klass: 'item_false'
        }, 
        {
            id: 2,
            klass: 'item_false_active'
        }]
    }]
    

    Таким образом, не будет проблемы временной сложности O(n³).

  3. Эффективно используйте кеш

    Я обнаружил, что getItemClass написан не очень хорошо, на самом деле я должен использовать переменную для кэширования вопросов, чтобы не оценивать вопросы несколько раз, а затем много раз обращаться к зависимому массиву исходного кода.

    const questions = this.questions
    
    // good           // bad
    // questions[0]   this.questions[0] 
    // questions[1]   this.questions[1]
    // questions[2]   this.questions[2]
    ......
    
    // 前者只会对 this.questions 一次求值,后者会三次求值
    

запоздалая мысль

Я многому научился на этом уроке.

  • При возникновении проблемы используйте существующие инструменты для анализа причины проблемы, такие как производительность, которая поставляется с Chrome.
  • Для технологии, которую вы используете, вы должны докопаться до ее сути.Я рад, что ранее я подробно изучил исходный код Vue, так что я могу легко решить проблему.В противном случае я все еще в замешательстве.Если у вас есть небольшой партнер, который хочет глубже понять Vue, вы можете обратиться кДемистификация технологии Vue.js, я видел много анализов исходного кода на GitHub, этот должен быть самым полным и лучше всего написанным, и я сам упомянул PR для этого анализа исходного кода. Если самообучение затруднено, рассмотритеСопутствующее видео, в конце концов, вооружитесь знаниями и никогда не мучайтесь в IT-мире.
  • Реализовать требование легко, но стоимость максимальной производительности может резко возрасти.