Vue 3: когда свойство computed может быть неправильным инструментом

Spread the love

Свободный перевод статьи: Thorsten Lünborg — Vue: When a computed property can be the wrong tool

Если вы используете Vue в свой работе, то вы вероятно, знаете свойства computed (вычисляемое свойство), а если вы похожи на меня, вы, вероятно, думаете, что это очень полезный инструмент!

Для меня computed — это очень эргономичный и элегантный способ работы с производным состоянием, то есть состоянием, которое состоит из другого состояния (его зависимостей). Но в некоторых сценариях это свойство может ухудшить производительность приложения, и как мне кажется, многие люди не знают об этом, поэтому эта статья посвящена объяснению этой проблемы.

Чтобы прояснить, что мы имеем ввиду, когда мы говорим computed во Vue, рассмотрим этот небольшой пример:

const todos = reactive([
  { title: 'Wahs Dishes', done: true},
  { title: 'Throw out trash', done: false }
])

const openTodos = computed(
  () => todos.filter(todo => !todo.done)
)

const hasOpenTodos = computed(
  () => !!openTodos.value.length
)

Здесь openTodos является производным от todos, а hasOpenTodos — производным от openTodos. В этом примере у нас есть реактивные объекты, которые мы можем передавать и использовать, и они будут автоматически обновляться при изменении состояния, от которого они зависят.

Если мы используем эти реактивные объекты в реактивном контексте, таком как шаблон Vue, функция рендеринга или watch(), также будут реагировать на изменения нашего вычисляемого свойства и обновлять его результат.

Примечание: в этой статье я использую Composition API , просто потому что оно мне больше нравиться на данный момент. Однако поведение, описанное в этой статье, применимо и к вычисляемым свойствам в обычном Options API . В конце концов, оба используют одну и ту же систему реактивности.

Что особенного в computed

Есть два нюанса computed, которые делают их особенными и имеют отношение к сути этой статьи:

  1. Их результаты кэшируются, и их необходимо повторно вычислять только после изменения одной из его реактивных зависимостей.
  2. Они вычисляются отложенным способом (evaluated lazily), то есть только при доступе.

Кеширование

Результат computed кэшируется. В нашем примере выше это означает, что до тех пор, пока массив задач todos не изменяется, многократный вызов openTodos.value вернет одно и то же значение без повторного запуска метода фильтрации. Это особенно удобно для дорогостоящих задач (задач потребляющих много ресурсов), так как это гарантирует, что задача будет запускаться повторно только тогда, когда это необходимо, а именно, когда одна из ее реактивных зависимостей изменилась.

Lazy Evaluation

Computed свойства также evaluated lazily — но что именно это означает?

Это означает, что функция обратного вызова свойства computed (callback) будет запускаться только после того, как вычисленное значение будет прочитано (изначально или после того, как оно было помечено для обновления, потому что одна из его зависимостей изменилась).

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

Когда lazy evaluation может улучшить производительность

Как объяснялось в предыдущем абзаце, lazy evaluation свойств computed очень полезно для дорогостоящих операций: оно гарантирует, что вычисление выполняется только тогда, когда результат действительно необходим. А это означает, что фильтрация большого списка, будет просто пропущена, если этот отфильтрованный результат не будет прочитан и использован какой-либо частью вашего кода в этот момент. Вот краткий пример:

<template>
  <input type="text" v-model="newTodo">
  <button type="button" v-on:click="addTodo">Save</button>
  <button @click="showList = !showList">
    Toggle ListView
  </button>
  <template v-if="showList">
    <template v-if="hasOpenTodos">
      <h2>{{ openTodos.length }} Todos:</h2> 
      <ul>
        <li v-for="todo in openTodos">
          {{ todo.title }}
        </li>
      </ul>
    </template>
    <span v-else>No todos yet. Add one!</span>
  </template>
</template>

<script setup>
const showListView = ref(false)

const todos = reactive([
  { title: 'Wahs Dishes', done: true},
  { title: 'Throw out trash', done: false }
])
const openTodos = computed(
  () => todos.filter(todo => !todo.done)
)
const hasOpenTodos = computed(
  () => !!openTodos.value.length
)

const newTodo = ref('')
function addTodo() {
  todos.push({
    title: todo.value,
    done: false
  })
}
</script>

Проверить этот код можно тут SFC Playground

Поскольку showList изначально имеет значение false, функция рендеринга не будет читать openTodos, и, следовательно, фильтрация даже не будет запускаться ни изначально, ни после добавления нового todo и изменения todos.length. Только после того, как для showList будет установлено в значение true, эти вычисленные свойства будут прочитаны, и это вызовет их вычисление.

Конечно, в этом небольшом примере объем работы по фильтрации минимален, но вы можете себе представить, что для более дорогих операций это может быть огромным преимуществом.

Когда lazy evaluation может снизить производительность

У этого есть обратная сторона: если результат, возвращаемый computed, будет известен только после того, как ваш код где-то его использует, то система реактивности Vue не узнает его возвращаемое значение заранее.

Другими словами, Vue может выяснить, что одно или несколько зависимостей свойства computed изменилось, поэтому его следует повторно вычислить при следующем чтении, но Vue не может в этот момент понять, был ли результат, возвращенный свойством computed другим.

Почему это может быть проблемой?

Другие части вашего кода могут зависеть от свойства computed — это может быть другое свойство computed, или это может быть функция watch(), или какая другая функция.

Таким образом, у Vue нет другого выбора, кроме как пометить это свойство для обновления — «на всякий случай» вдруг возвращаемое значение будет другим.

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

Демонстрация проблемы

Вот краткий пример: представьте, что у нас есть список элементов и кнопка для увеличения счетчика. Как только счетчик достигнет 100, мы хотим показать список в обратном порядке.

(Вы можете поиграть с этим примером тут SFC playground)

<template>
  <button @click="increase">
    Click me
  </button>
  <br>
  <h3>
    List
  </h3>
  <ul>
    <li v-for="item in sortedList">
      {{ item }}
    </li>
  </ul>
</template>

<script setup>
import { ref, reactive, computed, onUpdated } from 'vue'

const list = reactive([1,2,3,4,5])

const count = ref(0)
function increase() {
  count.value++
}

const isOver100 = computed(() => count.value > 100)

const sortedList = computed(() => {
  // imagine this to be expensive
  return isOver100.value ? [...list].reverse() : [...list]
})

onUpdated(() => {
  // this eill log whenever the component re-renders
  console.log('component re-rendered!')
})
</script>

Вопрос: Вы нажимаете кнопку в 101 раз. Будет ли в этот момент наш компонент перерисовывается?

Ответ: Да, будет выполнен 101 повторный рендеринг

Я подозреваю, что некоторые из вас могли ожидать другого ответа, например: «один раз, при 101-м щелчке». Но это неверный ответ, и причина этого — lazy evaluation свойств computed.

Давай те рассмотрим, что происходит, шаг за шагом:

  1. Когда мы нажимаем кнопку, увеличивается count. Компонент не будет повторно визуализироваться, потому что мы не используем счетчик count в шаблоне.
  2. Но поскольку count изменился, наше свойство computed isOver100 помечено как «dirty» — реактивная зависимость изменилась, и поэтому его возвращаемое значение должно быть повторно вычислено.
  3. Но из-за lazy evaluation это произойдет только после того, как что-то еще прочитает isOver100.value — до того, как это произойдет, мы (и Vue) не знаем, вернет ли это свойство computed false или изменится на true.
  4. sortedList зависит от isOver100, поэтому оно также должен быть помечен как dirty. Точно так же оно еще не будет вычислено, потому что это происходит только тогда, когда оно читается.
  5. Поскольку наш шаблон зависит от sortedList, и он помечен как «dirty» (потенциально изменен, требует вычисления), компонент будет повторно рендерится.
  6. Во время рендеринга он считывает sortedList.value
  7. Теперь вычисляется sortedList и считывается isOver100.value — который теперь вычисляется, и при этом снова возвращает false.
  8. Итак, теперь мы повторно отрисовали компонент и повторно запустили «дорогое» свойство computed sorteList , хотя все это было ненужным — получившийся новый шаблон будет выглядеть точно так же.

Настоящим виновником является isOver100 — это computed свойство, которое часто обновляется, но возвращает одно и то же значение, что и раньше, и, кроме того, это дешевая операция, которая на самом деле не приносит пользы от кэширования вычисляемых свойств. Мы просто использовали свойство computed, потому что оно показалось эргономичным и «красивым».

Как решить эту проблему, когда вы с ней сталкиваетесь.

К настоящему времени у вас может возникнуть два вопроса:

  1. Насколько это является проблемой?
  2. Как мне избегать эту проблему?

Итак, во-первых, остынь. Обычно это не большая проблема. Система реактивности Vue, как правило, очень эффективна, и повторные рендеры тоже, особенно во Vue 3. Обычно пара ненужных обновлений здесь и там по-прежнему будет работать намного лучше, чем ее аналог в React, который по умолчанию повторно рендерит все в любом состоянии.

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

Если вы столкнулись с такой ситуацией, к счастью, у вас есть разные способы ее решения:

  1. Использование простых функций вместо автономных свойств computed
  2. Использование Getters вместо свойств computed
  3. Использование настраиваемого свойства «eagerly computed»

Простые функции

Примечание

Этот раздел о простых функциях нуждается в тщательном пересмотре, я объединил несколько вещей из предыдущего черновика этой статьи, что в итоге привело к неверному выводу.
Можете сразу перейдите к третьему пункту о «eager сomputed», чтобы прочитать о безошибочном варианте решения.

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

// computed property
const hasOpenTodos = computed(() => !!openTodos.value.length)
// usage
if (hasOpenTodos.value) {
  // list open todos
}

// Simple function
const hasOpenTodos = () => !!openTodos.value.length
// Usage
if (hasOpenTodos()) {
  // list open todos
}

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

А простая функция не будет иметь lazy evaluation , поэтому мы не рискуем получить ненужные эффекты функции рендеринга, или другого свойства computed .

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

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

Getters

Я также видел такой образец кода:

import { reactive, computed } from 'vue'
const state = reactive({
  name: 'Linusborg',
  bigName: computed(() => state.name.toUpperCase())
})

И он может быть удобным, если вы хотите иметь объект, в котором одни свойства наследуются от других.

Но на самом деле computed в этом примере было бы излишним. В Javascript есть собственный способ получения состояния для свойства объекта, называемый Getters . У него нет кеширования или lazy evaluation, в любом случае мы не особо выигрываем от использования такого сценарии.

import { reactive } from 'vue'
const state = reactive({
  name: 'Linusborg',
  get bigName() { return state.name.toUpperCase() )
})

Пользовательский eagerComputed helper

Функции и геттеры — это хорошо, но для тех из нас, кто привык к тому, как работает Vue, computed может показаться более приятным. К счастью, система реактивности Vue дает нам все необходимые инструменты для создания нашей собственной версии computed(), которая вычисляется eagerly (моментально), а не как lazily (отложено).

Назовем это eagerComputed()

import { watchEffect, shallowRef, readonly } from 'vue'
export function eagerComputed(fn) {
  const result = shallowRef()
  watchEffect(() => {
    result.value = fn()
  }, 
  {
    flush: 'sync' // needed so updates are immediate.
  })

  return readonly(result)
}

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

Посмотрите пример на this SFC Playground

Когда лучше использовать computed(), а когда eagerComputed()?

  • Используйте computed(), когда у вас происходит сложное вычисление, которое может фактически использовать пользу из кеширования и отложенных вычислений и должно повторно вычисляться только в случае необходимости.
  • Используйте eagerComputed(), когда у вас простая операция с редко меняющимся возвращаемым значением — часто с логическим значением.

Примечание: имейте в виду, что это все равно добавит немного накладных расходов, поскольку использует набор API-интерфейсов реактивности — простая функция обычно более эффективна в очень чувствительных сценариях.

Заключение

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

Я надеюсь, что это статья была полезной для вас.

Была ли вам полезна эта статья?
[5 / 5]

Spread the love
Подписаться
Уведомление о
guest
1 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Анонимно
Анонимно
3 лет назад

Перевод ужас