Вот небольшой тест, который я сделал, чтобы исследовать ненужный повторный рендеринг узлов для списков в vue3 (vue2 ведет себя так же): https://kasheftin.github.io/vue3-rerender/ . Это исходный код: https://github.com/Kasheftin/vue3-rerender/tree/master.
Я пытаюсь понять, почему vue в некоторых случаях повторно отображает уже обработанные узлы в v-for. Я знаю (и приведу ниже) некоторые приемы, позволяющие избежать повторного рендеринга, но для меня крайне важно понять теорию.
Для тестов я добавил фиктивную директиву v-test, которая регистрируется только при срабатывании хуков mount/beforeUnmount.
Тест 1
<div v-for = "i in n" :key = "i">
<div>{{ i }}</div>
<div v-test = "log2">{{ log(i) }}</div>
</div>
Результат: все узлы перерисовываются при увеличении n. Почему? Как этого избежать?
Тест 2
Test2.vue:
<RerenderNumber v-for = "i in n" :key = "i" :i = "i" />
RerenderNumber.vue:
<template>
<div v-test = "log2">{{ log() }}</div>
</template>
Результат: Работает корректно. Перемещение внутреннего содержимого из test1 в отдельный компонент устраняет проблему. Почему?
Тест 3
<RerenderObject v-for = "i in n" :key = "i" :test = "{ i: { i: { i } } }" />
Результат: ненужный повторный рендеринг. Кажется, не разрешено создавать объекты на лету в цикле перед отправкой их какому-либо дочернему компоненту, вероятно, потому, что {} != {}
в JavaScript.
Тест 4
<template>
<RerenderNumberStore v-for = "item in items" :key = "item.id" :item = "item" />
</template>
<script>
export default {
computed: {
items () {
return this.$store.state.items
}
},
methods: {
addItem () {
this.$store.commit('addItem', { id: this.items.length, name: `Item ${this.items.length}` })
}
}
}
</script>
Здесь используется простейшее хранилище vuex. Он работает корректно — нет ненужного повторного рендеринга, несмотря на то, что item prop — это объект.
Тест 5
<RerenderNumberStore v-for = "item in items" :key = "item.id" :item = "{ id: item.id, name: item.name }" />
То же, что и тест 4, но item prop реструктурирован — и мы получаем ненужный повторный рендеринг.
Тест 6
Test6.vue:
<RerenderNumberStoreById v-for = "item in items" :key = "item.id" :item-id = "item.id" />
RerenderNumberStoreById.vue:
<template>
<div v-test = "log">{{ item.name }}</div>
</template>
<script>
export default {
props: ['itemId'],
computed: {
item () { return this.$store.state.items.find(item => item.id === this.itemId) }
}
}
</script>
Результат: ненужный повторный рендеринг. Почему? Я не могу найти причину, по которой поведение отличается от теста 4. Это менее понятно для меня - вычисляемый элемент никак не изменяется, когда новый элемент добавляется в массив элементов. Он возвращает тот же самый объект. Оно должно быть кэшировано, соответствовать предыдущему значению и не вызывать никаких обновлений в DOM.
Vue — реактивная система, поэтому, чтобы ответить на этот вопрос, нужно понимать, как работают кешируемые наблюдаемые и какова их гранулярность. Так что, пожалуйста, потерпите меня.
Представьте, что у вас есть дорогая функция, например.
getCurrentTotal() { return state.x + state.y; }
и у него нет побочных эффектов, т.е. для одних и тех же x
и y
результат точно такой же, и нам никогда не нужно вызывать его снова, если только одно из значений не изменится.
Чтобы включить наблюдение, вы должны придумать некоторую оболочку, например
const state = reactive({x:1,y:2,z:3})
Эта оболочка создаст карту наблюдателей:
--- initial state ---
x -> []
y -> []
z -> []
(неважно где эта карта "живет" или в каком виде, стратегий много)
Это также создаст кеш результатов.
Когда ваша функция вызывается в первый раз (она же «пробный прогон»), каждый доступ к реактивному state
объекту запоминается, а карта наблюдателей обновляется до:
--- after first run of getCurrentTotal() ---
x -> [getCurrentTotal]
y -> [getCurrentTotal]
z -> []
и кеш результатов получится getCurrentTotal,{x:1, y:2} -> 3
(упрощенно).
Теперь, если вы сделаете что-то вроде
state.x++
установщик для state.x
обнаружит, что его нужно снова запустить getCurrentTotal()
, потому что {x:2, y:2}
нет в кеше, и вуаля, у вас есть обновление.
Теперь TLDR:
В вашем первом примере Test1 наблюдаемая функция — это весь цикл for:
observedRenderer1() {
for i in n:
add or modify (if :key exists) a div and inside put all the stuff
}
Обратите внимание, что он будет вызываться при любом изменении n
и будет проходить весь цикл. Здесь нет ярлыков.
Во втором примере Test2,
observedRenderer2() {
for i in n:
callSomeOtherRenderer(i)
}
Ага! Петля все еще там. Но теперь наша единица работы более детализирована. Реактивная система проверяет свой кеш и не вызывает рендереры для RerenderNumber(1)
или RenderNumber(2)
, если у нее уже есть эти результаты.
Реальность немного сложнее, Vue хранит копии всех результатов в Virtual DOM (не путать с Shadow DOM!), где он хранит достаточно информации, чтобы знать shouldComponentUpdate
или нет. Да, можно было бы создать VNode в виртуальном дереве для каждого div в итерации цикла. Но тогда для плотной таблицы 100x100 ячеек в вашем дереве будет 10 тысяч объектов, и, как пользователь Vue, вы никогда не сможете его оптимизировать.
Хотя ваш вопрос выглядит как обнаружение ошибки, на самом деле это мощный механизм, дающий вам точный контроль над степенью детализации ваших обновлений. Компромисс между памятью и скоростью.
Test3 (или Test5) терпит неудачу по более глубокой причине, но по той же причине: вы создаете новые объекты на каждой итерации, и вызывать для них глубокое равенство во время повторного рендеринга слишком дорого в реальной жизни. Передайте их как отдельные реквизиты, такие как Test4, и все будет в порядке.
Тест 6 легко объяснить, если вы думаете, что во время пробного прогона каждый элемент должен был пройти через весь набор элементов, поэтому карта зависимостей каждого визуализированного RerenderNumberStoreById
состоит из каждого элемента в списке.
Спасибо за отличное объяснение. Можем ли мы как-то преобразовать это в руководство? Следующий вопрос, который возникает, — как избежать повторного рендеринга. По сути, у нас есть некоторые элементы [] в магазине, некоторый компонент ItemList, который перебирает ItemEntry, и последний не должен использовать какой-либо геттер (нижний функционал, itemById: (state) => (itemId) => state.items.find( item => item.id === itemId), ни карта, itemByIds: (state) => state.items.reduce((out, item) => { out[item.id] = item; return out}, { })). Но мы можем использовать геттер в ItemList. Я не видел такого руководства, есть ли оно?