[Перевод] Vue.js: 3 анти-паттерна

Вероятно, Vue.js — это один из приятнейших JavaScript-фреймворков. У него имеется интуитивно понятный API, он быстрый, гибкий, им легко пользоваться. Однако гибкость Vue.js соседствует с определёнными опасностями. Некоторые разработчики, работающие с этим фреймворком, склонны к небольшим оплошностям. Это может плохо влиять на производительность приложений, или, в долгосрочной перспективе, на возможность их поддержки.

mtrpbsevaksphoeiwzgs4efaqkg.jpeg

Автор материала, перевод которого мы сегодня публикуем, предлагает разобрать некоторые распространённые ошибки, совершаемые теми, кто разрабатывает приложения на Vue.js.

Побочные эффекты внутри вычисляемых свойств


Вычисляемые свойства — это очень удобный механизм Vue.js, позволяющий организовывать работу с фрагментами состояния, зависящими от других фрагментов состояния. Вычисляемые свойства следует использовать только для вывода данных, хранящихся в состоянии и зависящих от других данных из состояния. Если оказывается, что вы вызываете внутри вычисляемых свойств некие методы или выполняете запись неких значений в другие переменные состояния, это может означать, что вы что-то делаете неправильно. Рассмотрим пример.

export default {
  data() {
    return {
      array: [1, 2, 3]
    };
  },
  computed: {
    reversedArray() {
      return this.array.reverse(); // Побочный эффект - изменение свойства с данными
    }
  }
};


Если мы попытаемся вывести array и reversedArray, то можно будет заметить, что оба массива содержат одни и те же значения.

исходный массив: [ 3, 2, 1 ] 
модифицированный массив: [ 3, 2, 1 ]


Это так из-за того, что вычисляемое свойство reversedArray модифицирует исходное свойство array, вызывая его метод .reverse(). Это — довольно простой пример, демонстрирующий неожиданное поведение системы. Взглянем на ещё один пример.

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

export default {
  props: {
    order: {
      type: Object,
      default: () => ({})
    }
  },
  computed:{
    grandTotal() {
      let total = (this.order.total + this.order.tax) * (1 - this.order.discount);
      this.$emit('total-change', total)
      return total.toFixed(2);
    }
  }
}


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



export default {
  // другие свойства в этом примере неважны
  methods: {
    totalChange(grandTotal) {
      if (this.isSpecialCustomer) {
        this.order = {
          ...this.order,
          discount: this.order.discount + 0.1
        };
      }
    }
  }
};


Теперь представим, что иногда, хотя и очень редко, возникают ситуации, в которых мы работаем с особенными покупателями. Этим покупателям мы даём дополнительную скидку в 10%. Мы можем попытаться изменить объект order и увеличить размер скидки, прибавив 0.1 к его свойству discount.

Это, однако, приведёт к нехорошей ошибке.

0678651933e04231a483eeb7497c8dac.png


Сообщение об ошибке

e312acdb688bafe5b4af85077c5f6c02.png


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

В подобной ситуации происходит следующее: вычисляемое свойство постоянно, в бесконечном цикле, «пересчитывается». Мы меняем скидку, вычисляемое свойство на это реагирует, пересчитывает общую стоимость заказа и порождает событие. При обработке этого события скидка снова увеличивается, это вызывает пересчёт вычисляемого свойства, и так — до бесконечности.

Вам может показаться, что подобную ошибку невозможно совершить в реальном приложении. Но так ли это на самом деле? Наш сценарий (если нечто подобное произойдёт в настоящем приложении) будет очень сложно отладить. Подобную ошибку будет крайне непросто отследить. Дело в том, что для возникновения этой ошибки нужно, чтобы заказ оформлял бы особенный покупатель, а на один такой заказ, возможно, приходится 1000 обычных заказов.

Изменение вложенных свойств


Иногда у разработчика может появиться соблазн отредактировать что-то в свойстве из props, являющемся объектом или массивом. Подобное желание может быть продиктовано тем фактом, что сделать это очень «просто». Но стоит ли так поступать? Рассмотрим пример.


export default {
  name: "HelloWorld",
  props: {
    product: {
      type: Object,
      default: () => ({})
    }
  },
  methods: {
    addToCart() {
      if (this.product.stock > 0) {
        this.$emit("add-to-cart");
        this.product.stock--;
      }
    }
  }
};


Здесь у нас имеется компонент Product.vue, который выводит название товара, его стоимость и имеющееся у нас количество товара. Компонент, кроме того, выводит кнопку, которая позволяет покупателю положить товар в корзину. Может показаться, что очень легко и удобно будет уменьшать значение свойства product.stock после щелчка по кнопке. Сделать это, и правда, просто. Но если поступить именно так — можно столкнуться с несколькими проблемами:

  • Мы выполняем изменение (мутацию) свойства и ничего не сообщаем об этом родительской сущности.
  • Это может привести к неожиданному поведению системы, или, что ещё хуже, к появлению странных ошибок.
  • Мы вводим в компонент product некую логику, которая, вероятно, не должна в нём присутствовать.


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


import Product from "./components/Product";
export default {
  name: "App",
  components: {
    Product
  },
  data() {
    return {
      product: {
        name: "Laptop",
        price: 1250,
        stock: 2
      }
    };
  },
  methods: {
    addProductToCart(product) {
      if (product.stock > 0) {
        product.stock--;
      }
    }
  }
};


Ход мыслей этого разработчика может быть следующим: «Видимо, мне нужно уменьшить product.stock в методе addProductToCart». Но если так и будет сделано — мы столкнёмся с небольшой ошибкой. Если теперь нажать на кнопку, то количество товара будет уменьшено не на 1, а на 2.

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

Если этот пример показался вам неубедительным — представим себе ещё один сценарий. Пусть это будет форма, которую заполняет пользователь. Сущность user мы передаём в форму в качестве свойства и собираемся отредактировать имя (name) и адрес электронной почты (email) пользователя. Код, который показан ниже, может показаться «правильным».

// Родительский компонент

 import UserForm from "./UserForm"
 export default {
  components: {UserForm},
  data() {
   return {
     user: {
      email: 'loreipsum@email.com',
      name: 'Lorem Ipsum'
     }
   }
  },
  methods: {
    updateUser() {
     // Отправляем на сервер запрос на сохранение данных пользователя
    }
  }
 }
// Дочерний компонент UserForm.vue

 export default {
  props: {
    user: {
     type: Object,
     default: () => ({})
    }
  }
 }


Здесь легко наладить работу с user с помощью директивы v-model. Vue.js это позволяет. Почему бы не поступить именно так? Подумаем об этом:

  • Что если имеется требование, в соответствии с которым необходимо добавить на форму кнопку Cancel, нажатие на которую отменяет внесённые изменения?
  • Что если обращение к серверу оказывается неудачным? Как отменить изменения объекта user?
  • Действительно ли мы хотим выводить изменённые имя и адрес электронной почты в родительском компоненте перед сохранением соответствующих изменений?


Простой способ «исправления» проблемы может заключаться в клонировании объекта user перед отправкой его в качестве свойства:


Хотя это может и сработать, мы лишь обходим проблему, но не решаем её. Наш компонент UserForm должен обладать собственным локальным состоянием. Вот что мы можем сделать.


export default {
  props: {
    user: {
     type: Object,
     default: () => ({})
    }
  },
  data() {
   return {
    form: {}
   }
  },
  methods: {
   onSave() {
    this.$emit('submit', this.form)
   },
   onCancel() {
    this.form = {...this.user}
    this.$emit('cancel')
   }
  }
  watch: {
    user: {
     immediate: true,
     handler: function(userFromProps){
      if(userFromProps){
        this.form = {
          ...this.form,
          ...userFromProps
        }
      }
     }
    }
  }
 }


Хотя этот код, определённо, кажется довольно сложным, он лучше, чем предыдущий вариант. Он позволяет избавиться от вышеописанных проблем. Мы ожидаем (watch) изменений свойства user и копируем его во внутренние данные form. В результате у формы теперь есть собственное состояние, а мы получаем следующие возможности:

  • Отменить изменения можно, переназначив форму: this.form = {...this.user}.
  • У нас имеется изолированное состояние для формы.
  • Наши действия не затрагивают родительский компонент в том случае, если нам это не нужно.
  • Мы контролируем то, что происходит при попытке сохранения изменений.


Прямой доступ к родительским компонентам


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

Рассмотрим очень простой пример — компонент, реализующий выпадающее меню. Представим, что у нас имеется компонент dropdown (родительский), и компонент dropdown-menu (дочерний). Когда пользователь щёлкает по некоему пункту меню, нам нужно закрыть dropdown-menu. Скрытие и отображение этого компонента выполняется родительским компонентом dropdown. Взглянем на пример.

// Dropdown.vue (родительский компонент)