задний план
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, а затем контролируют их видимость из стиля:
Но эта реализация — пустая трата производительности, потому что нет необходимости рендерить столько столбцов вообще, по сути, нужно рендерить только DOM фиксированного отображаемого столбца, а затем можно делать высокую синхронизацию. Вот как реализован ZoomUI, а эффект следующий:
Конечно, только уменьшитьfixed
Для столбцов, отображаемых таблицей, улучшение производительности недостаточно очевидно. Есть ли способ продолжить оптимизацию измерения отображения столбцов?
Это оптимизация с бизнес-уровня.Для таблицы с 20 колонками часто бывает не так много ключевых колонок, поэтому можем ли мы отрисовывать только ключевые колонки для первой отрисовки, а остальные колонки отрисовываются по конфигурации?
В соответствии с вышеуказанными требованиями, я даюTable
Компонент добавляет следующий функционал:
Table
добавить компонентinitDisplayedColumn
Атрибут, с помощью которого можно настроить столбец для первоначального рендеринга, и когда пользователь модифицирует столбец для первоначального рендеринга, он будет сохранен во внешнем интерфейсе для следующего рендеринга.
Таким образом, мы можем отображать меньше столбцов. Очевидно, что если для рендеринга будет меньше столбцов, количество DOM для общего рендеринга таблицы будет уменьшено, а производительность также будет в определенной степени улучшена.
Обновлены оптимизации рендеринга
Конечно, оптимизировать отрисовку столбцов недостаточно, проблема, с которой мы сталкиваемся, заключается в том, что при нажатии на строку отрисовка зависает, почему она зависает?
Чтобы найти проблему, я используюTable
Компонент создает таблицу 1000 * 7 с включенным ведением журнала панели производительности Chrome.checkbox
Нажмите до и после выступления.
после нескольких разcheckbox
После нажатия на поле выбора вы можете увидеть следующий график пламени:
Желтая частьScripting
Время выполнения скрипта, фиолетовая частьRendering
занятое время. Снова перехватим процесс обновления:
Затем посмотрите дерево вызовов, выполняемое JS-скриптом, и обнаружите, что время в основном тратится наTable
Об обновлении рендеринга компонента:
Мы нашли компонент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.store
7000 сборов зависимостей, что приводит к пустой трате производительности, а настоящий сбор зависимостей нужно выполнить только один раз.
Решить эту задачу несложно, потому что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
представление.
Взгляните на оптимизированный график пламени:
Кажется, что площадьScripting
Время выполнения сокращается, давайте посмотрим на время выполнения JS, необходимое для одного обновления:
Мы нашли компонент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
Соответствующая функция обратного вызова будет добавлена к следующей задаче макроса.Когда функция обратного вызова выполняется, это означает, что выполнение предыдущей задачи макроса завершено.В это время относительно точно вычислить производительность путем расчета разницы во времени .
На основе рукописного бенчмарка получены следующие результаты тестирования:
ElementUI Table
Обновление компонента занимает около 900 мс.
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
Идея реализации оптимизации производительности табличных компонентов. Давайте посмотрим на его эффект на графике пламени:
мы нашли желтыйScripting
Время почти ушло, давайте посмотрим на время выполнения JS, которое требуется для одного обновления:
Мы нашли компонентrender to vnode
Это занимает около 20 мс;vnode patch to DOM
Это занимает около 1 мс, а время выполнения JS значительно сокращается на протяжении всего процесса рендеринга обновления.
Дополнительно проходимbenchmark
Протестируйте и получите следующие результаты:
После оптимизации ZoomUITable
Время обновления компонента составляет около 80 мс по сравнению с ElementUI.Table
компоненты,Примерно в десять раз лучше производительность.
Этот эффект оптимизации просто потрясающий, и по производительности он уже не уступает jQuery Table, и мой двухлетний узел решен.
Суммировать
Table
Есть три основных аспекта улучшения производительности таблиц: уменьшение количества DOM, оптимизацияrender
обрабатывать и повторно использоватьvnode
. Иногда мы также можем подумать с точки зрения бизнеса и провести некоторую оптимизацию.
Несмотря на то чтоuseMemo
Реализация все еще грубая, но она удовлетворяет нашим сценариям использования в настоящее время, и когда объем данных больше, чем больше строк и столбцов отображается, тем более очевидным будет этот эффект оптимизации. Если в будущем возникнут дополнительные потребности, хорошо обновить и повторить.
По некоторым причинам наша компания до сих пор использует Vue 2, но это не мешает мне изучать Vue 3. Понимание принципов реализации и идей дизайна некоторых его новых функций позволит мне развить множество идей.
Я надеюсь, что эта статья может дать вам некоторые идеи по оптимизации производительности компонентов и применить их в вашей повседневной работе — от анализа и позиционирования проблемы до ее окончательного решения.