За одну операцию я увеличил производительность компонента Table в десять раз

Element оптимизация производительности Vue.js
За одну операцию я увеличил производительность компонента Table в десять раз

задний план

TableПрименение табличных компонентов в веб-разработке можно увидеть повсюду, но когда объем табличных данных велик, возникают проблемы с производительностью: рендерится слишком много DOM, а рендеринг и взаимодействие будут иметь определенную степень отставания.

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

Помню, в 2019 году я внедрил оптимизационное решение на основе Vue.js для разделения фронтенда и бэкенда в Zoom и разработал ZoomUI на основе библиотеки компонентов ElementUI. Среди них мы использовали ZoomUI при рефакторинге страницы управления пользователями.TableКомпонент заменяет старый, разработанный с помощью jQuery.Tableкомпоненты.

Потому что большинство сценTableКомпоненты разбиты на страницы, поэтому проблем с производительностью нет. Но в особом сценарии: поиск по ключевым словам, может быть 200*20 результатов без пагинации, а в таблице есть столбец сcheckbox, то есть можно выделить определенные строки для работы.

Когда мы пошли кликать по одной из строк, то обнаружили, что выбор занимает много времени, и есть явное ощущение заикания, в то время как в предыдущей версии jQuery таких проблем не было, что удивительно. Должен ли хороший технический рефакторинг жертвовать пользовательским опытом?

Первая попытка оптимизации компонента Table

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

Оптимизация отображения столбцов

Во-первых, ZoomUI отображает больше DOM, чем jQuery.Table, поэтому первое направление мышления состоит в том, чтобы позволитьTableкомпонентыМаксимально сведите к минимуму количество рендеров DOM..

Данные в 20 столбцов обычно отображаются под экраном не полностью, старая реализация jQuery Table очень проста, с полосой прокрутки внизу, а ZoomUI поддерживает фиксацию левого и правого столбцов в этом прокручиваемом столбце, так что во время левого и правый скользящий процесс, вы можете исправить некоторые столбцы, чтобы они отображались постоянно, и пользовательский опыт улучшится, но эта реализация имеет определенную цену.

Чтобы добиться этого макета с фиксированными столбцами, ElementUI использует 6tableтеги реализовать, так зачем вам 6tableЧто насчет этикеток?

Во-первых, чтобы позволитьTableКомпонент поддерживает расширенные функции заголовков, каждая из которых используется для заголовка и тела.tableтеги для достижения. Так что за столом будет 2tableметка, затем добавьте левуюfixedстол и правоfixedвсего 6 столовtableЭтикетка.

В реализации ElementUI левыйfixedстол и правоfixedТаблицы отображают полные столбцы из DOM, а затем контролируют их видимость из стиля:

element.png

element1.png

Но эта реализация — пустая трата производительности, потому что нет необходимости рендерить столько столбцов вообще, по сути, нужно рендерить только DOM фиксированного отображаемого столбца, а затем можно делать высокую синхронизацию. Вот как реализован ZoomUI, а эффект следующий:

zoom-ui.pngКонечно, только уменьшитьfixedДля столбцов, отображаемых таблицей, улучшение производительности недостаточно очевидно. Есть ли способ продолжить оптимизацию измерения отображения столбцов?

Это оптимизация с бизнес-уровня.Для таблицы с 20 колонками часто бывает не так много ключевых колонок, поэтому можем ли мы отрисовывать только ключевые колонки для первой отрисовки, а остальные колонки отрисовываются по конфигурации?

В соответствии с вышеуказанными требованиями, я даюTableКомпонент добавляет следующий функционал:

zoom-ui1.png

Tableдобавить компонентinitDisplayedColumnАтрибут, с помощью которого можно настроить столбец для первоначального рендеринга, и когда пользователь модифицирует столбец для первоначального рендеринга, он будет сохранен во внешнем интерфейсе для следующего рендеринга.

Таким образом, мы можем отображать меньше столбцов. Очевидно, что если для рендеринга будет меньше столбцов, количество DOM для общего рендеринга таблицы будет уменьшено, а производительность также будет в определенной степени улучшена.

Обновлены оптимизации рендеринга

Конечно, оптимизировать отрисовку столбцов недостаточно, проблема, с которой мы сталкиваемся, заключается в том, что при нажатии на строку отрисовка зависает, почему она зависает?

Чтобы найти проблему, я используюTableКомпонент создает таблицу 1000 * 7 с включенным ведением журнала панели производительности Chrome.checkboxНажмите до и после выступления.

после нескольких разcheckboxПосле нажатия на поле выбора вы можете увидеть следующий график пламени:

element2.png

Желтая частьScriptingВремя выполнения скрипта, фиолетовая частьRenderingзанятое время. Снова перехватим процесс обновления:

element3.png

Затем посмотрите дерево вызовов, выполняемое JS-скриптом, и обнаружите, что время в основном тратится наTableОб обновлении рендеринга компонента:

element4.png

Мы нашли компонентrender to vnodeЗатраченное время составляет около 600 мс;vnode patch to DOMЗатраченное время составляет около 160 мс.

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

А так как имеется 1000 * 7 фрагментов данных, вся таблица должна пройти цикл 1000 * 7 раз, чтобы создать самый внутреннийtd, весь процесс займет много времени.

Так есть ли место для оптимизации внутри цикла? Для ElementUITableКомпоненты, здесь есть много возможностей для оптимизации.

На самом деле идея оптимизации в основном относится к тому, что я писал ранее«Демистификация девяти советов по оптимизации производительности для Vue.js»один из нихLocal variablesНавык. Например, в ElementUITableкомпонент, после рендеринга каждогоtdКогда есть такой кусок кода:

const data = {
  store: this.store,
  _self: this.context || this.table.$vnode.context,
  column: columnData,
  row,
  $index
}

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

Благодаря дизайну адаптивной системы Vue.js каждое посещениеthis.storeКогда ответные данные запускаются, внутреннийgetterфункцию, а затем выполнить ее сбор зависимостей, когда этот код зациклится 1000 * 7 раз, он будет выполненthis.store7000 сборов зависимостей, что приводит к пустой трате производительности, а настоящий сбор зависимостей нужно выполнить только один раз.

Решить эту задачу несложно, потому чтоTableв компонентеTableBodyиспользуются компонентыrenderфункция написана, мы можем написать ее в компонентеrenderНекоторые локальные переменные определены на входе функции:

render(h) {
  const { store /*...*/} = this
  const context = this.context ||  this.table.$vnode.context
}

а затем рендеринг всегоrenderВо время процесса локальная переменная передается как параметр внутренней функции, так что внутренний рендерингtdПовторный доступ к этим переменным в средстве визуализации не вызовет сбор зависимостей:

rowRender({store, context, /* ...其它变量 */}) {
  const data = {
    store: store,
    _self: context,
    column: columnData,
    row,
    $index,
    disableTransition,
    isSelectedRow
  }
}

Таким образом, мы модифицировали аналогичные коды для достиженияTableBodyДоступ к этим адаптивным переменным внутри функции рендеринга компонента вызывает эффект сбора зависимостей только один раз, тем самым оптимизируяrenderпредставление.

Взгляните на оптимизированный график пламени:

zoom-ui2.png

Кажется, что площадьScriptingВремя выполнения сокращается, давайте посмотрим на время выполнения JS, необходимое для одного обновления:

zoom-ui3.png

Мы нашли компонентrender to vnodeЗатраченное время составляет около 240 мс;vnode patch to DOMЗатраченное время составляет около 127 мс.

Как видите, ZoomUITableкомпонентrenderвремя иupdateВремя значительно меньше, чем у ElementUITableкомпоненты.renderСокращение времени связано со значительным сокращением времени для адаптивного сбора зависимостей переменных,updateСокращение времени происходит за счетfixedУменьшено количество DOM для рендеринга таблицы.

С точки зрения пользователя, DOM обновляется в дополнение кScriptingвремя иRenderingвремя, они делят тему, конечно из-за ZoomUITableКомпонент отображает меньше DOM, выполняетсяRenderingвремя тоже короче.

рукописный эталон

Тестирование только с панели «Производительность» не является особенно точным эталоном, мы можемTableКомпонент пишет бенчмарк.

Сначала мы можем создать кнопку для имитацииTableОперация выбора компонентов:

<div>
  <zm-button @click="toggleSelection(computedData[1])
">切换第二行选中状态
  </zm-button>
</div>
<div>
  更新所需时间: {{ renderTime }}
</div>

затем реализовать этоtoggleSelectionфункция:

methods: {
 toggleSelection(row) {
   const s = window.performance.now()
   if (row) {
     this.$refs.table.toggleRowSelection(row)
   }
   setTimeout(() => {
     this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
   })
 }
}

В функции обратного вызова события клика мы передаемwindow.performance.now()запишите время начала, а затемsetTimeoutВ функции обратного вызова просмотрите разницу во времени, чтобы рассчитать время, необходимое для рендеринга всего обновления.

Поскольку выполнение JS и рендеринг пользовательского интерфейса занимают один и тот же поток, во время выполнения задачи макроса будут выполняться эти две задачи, иsetTimeout 0Соответствующая функция обратного вызова будет добавлена ​​к следующей задаче макроса.Когда функция обратного вызова выполняется, это означает, что выполнение предыдущей задачи макроса завершено.В это время относительно точно вычислить производительность путем расчета разницы во времени .

На основе рукописного бенчмарка получены следующие результаты тестирования:

element5.png

ElementUI Table Обновление компонента занимает около 900 мс.

zoom-ui4.png

ZoomUI Table Время обновления компонента составляет около 280 мс по сравнению с ElementUI.Tableкомпоненты,Примерно в три раза выше производительность.

Вдохновленный v-memo

После этой оптимизации проблема, упомянутая в начале статьи, в основном решена.При выборе столбца в таблице 200*20 нет явного ощущения застревания, но по сравнению с Таблицей, реализованной jQuery, эффект все же есть немного хуже.

Хотя производительность была оптимизирована три раза, у меня все еще есть сердце: очевидно, обновляется только выбранное состояние одной строки данных, но вся таблица все еще перерендеривается, и компонент все еще нуждается в перерендеринге.renderВ процессе выполнения нескольких цикловpatchв процессеdiffАлгоритмы сравнения обновлений.

Недавно я изучал Vue.js 3.2.v-memoПосле прочтения исходного кода я был очень взволнован, потому что казалось, что этот прием оптимизации можно применить к компоненту таблицы ZoomUI, хотя наша библиотека компонентов была разработана на основе версии Vue 2.

Я провел полдня, после некоторых попыток это удалось, так как вы это сделали? Не волнуйтесь, мы начнемv-memoпринципа реализации.

Принцип реализации v-memo

v-memoЭто новая директива, добавленная в Vue.js версии 3.2. Ее можно использовать для обычных тегов или списков в сочетании сv-forИспользуйте, в официальной документации сайта есть такой абзац:

v-memoЭто только для целевой оптимизации сценариев, чувствительных к производительности, и должно быть несколько сценариев, которые будут использоваться. оказыватьv-forДлинные списки (длиннее 1000), вероятно, являются наиболее полезными сценариями:

<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
  <p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
  <p>...more child nodes</p>
</div>

когда компонентselectedКогда состояние меняется, даже если подавляющее большинствоitemНичего не изменилось, все равно будет создано много VNodes. используется здесьv-memoПо сути означает «обновлять элемент только тогда, когда он становится выбранным из невыбранного и наоборот». Это позволяет каждому незатронутомуitemПовторно используйте предыдущий VNode и полностью пропустите сравнение различий. Обратите внимание, что нам не нужно ставитьitem.idВключен в массив мемоизированных зависимостей, поскольку Vue может автоматическиitemиз:keyсделать вывод.

На самом деле, если говорить прямоv-memoСуть в повторном использованииvnode, приведенный выше шаблонОнлайн-инструмент для компиляции шаблонов, видно, что соответствующиеrenderфункция:

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
    const _memo = ([item.id === _ctx.selected])
    if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
    const _item = (_openBlock(), _createElementBlock("div", {
      key: item.id
    }, [
      _createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
      _hoisted_1
    ]))
    _item.memo = _memo
    return _item
  }, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

на основеv-forСписок внутри черезrenderListФункция рендерится, и стоит посмотреть ее реализацию:

function renderList(source, renderItem, cache, index) {
  let ret
  const cached = (cache && cache[index])
  if (isArray(source) || isString(source)) {
    ret = new Array(source.length)
    for (let i = 0, l = source.length; i < l; i++) {
      ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
    }
  }
  else if (typeof source === 'number') {
    // source 是数字
  }
  else if (isObject(source)) {
    // source 是对象
  }
  else {
    ret = []
  }
  if (cache) {
    cache[index] = ret
  }
  return ret
}

Мы только анализируемsource, который представляет собой списокlistэто случай массива, для каждогоitem, выполнитrenderItemфункция для рендеринга.

генерируется изrenderфункция, вы можете видетьrenderItemРеализация выглядит следующим образом:

(item, __, ___, _cached) => {
    const _memo = ([item.id === _ctx.selected])
    if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
    const _item = (_openBlock(), _createElementBlock("div", {
      key: item.id
    }, [
      _createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
      _hoisted_1
    ]))
    _item.memo = _memo
    return _item
  }

существуетrenderItemВнутри функции_memoПеременная, используется для определения нужно ли получать из кешаvnodeмассив условий и четвертый параметр_cachedсоответствуетitemсоответствующий кэшvnode. Далее черезisMemoSameфункция судитьmemoЭто то же самое, давайте посмотрим на его реализацию:

function isMemoSame(cached, memo) {
  const prev = cached.memo
  if (prev.length != memo.length) {
    return false
  }
  for (let i = 0; i < prev.length; i++) {
    if (prev[i] !== memo[i]) {
      return false
    }
  }
  // ...
  return true
}

isMemoSameВнутри функция пройдетcached.memoпопасть в кэшmemo, а затем путем обхода и сравнения каждого условия, чтобы оценить и текущееmemoто же самое.

пока вrenderItemВ конце функции_memoкеш к текущемуitemизvnode, что удобно для следующего проходаisMemoSameсудить об этомmemoТот же ли он, если он тот же, значит, элемент не изменился, и сразу возвращает последний кэшированныйvnode.

Тогда этот кешvnodeГде именно он хранится?Изначально при инициализации экземпляра компонента проектировался кеш рендеринга:

const instance = {
  // ...
  renderCache: []
}

затем выполнениеrenderПри вызове функции передайте этот кеш вторым параметром:

const { renderCache } = instance
result = normalizeVNode(
  render.call(
    proxyToUse,
    proxyToUse,
    renderCache,
    props,
    setupState,
    data,
    ctx
  )
)

затем выполнениеrenderListфункция, поставить_cahceПередайте в качестве третьего параметра:

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
    // renderItem 实现
  }, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

Так что на самом деле список кэшируетсяvnodeхранятся в_cacheв, то естьinstance.renderCacheсередина.

Так зачем использовать кешvnodeоптимизироватьpatchпроцесс, потому что вpatchКогда функция выполняется, если она встречает новые и старыеvnodeТо же самое, просто вернитесь напрямую, ничего не делая.

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
  if(n1 === n2) {
    return
  }
  // ...
}

Видимо, из-за использования кешированногоvnode,Oниотносятся к одному и тому же объекту, вернуться напрямую, сохранив последующее выполнениеpatchвремя процесса.

Приложение в табличном компоненте

v-memoИдея оптимизации очень проста: повторно использовать кеш.vnode, что является идеей оптимизации изменения пространства во времени.

Итак, мы упоминали ранее, что выбор строки без изменения состояния табличного компонента можно получить и из кеша?

Следуя этому ходу мысли, я даюTableкомпонент разработанuseMemoэтоprop, который на самом деле специально используется для сценариев с выбранными столбцами.

затем вTableBodyкомпонентcreatedВ функции хука создается объект для кэширования:

created() {
  if (this.table.useMemo) {
    if (!this.table.rowKey) {
      throw new Error('for useMemo, row-key is required.')
    }
    this.vnodeCache = []
  }
}

почему здесьvnodeCacheопределяется какcreatedВ функции хука, потому что ему не нужно становиться реактивным объектом.

Также обратите внимание, что мы будемkeyкак тайникkey,следовательноTableкомпонентrowKeyатрибут обязателен.

Затем в процессе рендеринга каждой строки я добавлялuseMemoСвязанная логика:

function rowRender({ /* 各种变量参数 */}) {
  let memo
  const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
  let cached
  if (useMemo) {
    cached = this.vnodeCache[key]
    const currentSelection = store.states.selection
    if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
      return cached
    }
    memo = currentSelection.slice()
  }
  // 渲染 row,返回对应的 vnode
  const ret = rowVnode
  if (useMemo && columns.length) {
    ret.memo = memo
    this.vnodeCache[key] = ret
   }
   return ret
}

здесьmemoПеременная используется для записи выбранных данных строки, а также сохраняется в конце функции дляvnodeизmemo, что удобно для следующего сравнения.

на каждом рендереrowизvnodeраньше, по словамrowсоответствующийkeyПопытаться получить из кеша, если он существует, передатьisRowSelectionChangedчтобы определить, изменилось ли выбранное состояние строки; если оно не изменилось, оно сразу вернется к кэшированномуvnode.

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

Конечно, эта реализация сравнивается сv-memoНе настолько общий, только для сравнения состояния выбора строки, а не для сравнения изменений других данных. Вы можете спросить, если данные этой строки и определенного столбца изменены, но выбранное состояние не изменилось, неправильно ли снова обращаться к кешу?

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

watch: {
  'store.states.data'() {
    if (this.table.useMemo) {
      this.vnodeCache = []
    }
  }
}

Кроме того, мы поддерживаем опциональную функцию рендеринга столбца, а при изменении окна может измениться и скрытый столбец, поэтому в этих двух сценариях его тоже нужно очищатьvnodeCache:

watch:{
  'store.states.columns'() {
    if (this.table.useMemo) {
      this.vnodeCache = []
    }
  },
  columnsHidden(newVal, oldVal) {
    if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
      this.vnodeCache = []
    }
  }
}

Вышеупомянутая реализация основана наv-memoИдея реализации оптимизации производительности табличных компонентов. Давайте посмотрим на его эффект на графике пламени:

zoom-ui6.png

мы нашли желтыйScriptingВремя почти ушло, давайте посмотрим на время выполнения JS, которое требуется для одного обновления:

zoom-ui7.pngМы нашли компонентrender to vnodeЭто занимает около 20 мс;vnode patch to DOMЭто занимает около 1 мс, а время выполнения JS значительно сокращается на протяжении всего процесса рендеринга обновления.

Дополнительно проходимbenchmarkПротестируйте и получите следующие результаты:

zoom-ui5.pngПосле оптимизации ZoomUITable Время обновления компонента составляет около 80 мс по сравнению с ElementUI.Tableкомпоненты,Примерно в десять раз лучше производительность.

Этот эффект оптимизации просто потрясающий, и по производительности он уже не уступает jQuery Table, и мой двухлетний узел решен.

Суммировать

TableЕсть три основных аспекта улучшения производительности таблиц: уменьшение количества DOM, оптимизацияrenderобрабатывать и повторно использоватьvnode. Иногда мы также можем подумать с точки зрения бизнеса и провести некоторую оптимизацию.

Несмотря на то чтоuseMemoРеализация все еще грубая, но она удовлетворяет нашим сценариям использования в настоящее время, и когда объем данных больше, чем больше строк и столбцов отображается, тем более очевидным будет этот эффект оптимизации. Если в будущем возникнут дополнительные потребности, хорошо обновить и повторить.

По некоторым причинам наша компания до сих пор использует Vue 2, но это не мешает мне изучать Vue 3. Понимание принципов реализации и идей дизайна некоторых его новых функций позволит мне развить множество идей.

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