Три антипаттерна, которые следует избегать в Vue.js

Vue.js, вероятно, одна из самых приятных библиотек Javascript для frontend разработки. У него интуитивно понятный API, он быстрый, простой в использовании и гибкий. Однако наряду с гибкостью, некоторые разработчики часто попадают в небольшие ловушки, которые могут отрицательно сказаться на производительности приложения или долгосрочном обслуживании.

Итак, давайте углубимся в детали и посмотрим, каких распространенных ошибок следует избегать при разработке с помощью Vue.js.

Сайд-эффекты внутри вычисляемых свойств

Вычисляемые свойства в Vue.js - очень удобный способ управлять состоянием, которое зависит от другого состояния. Вычисляемые свойства следует использовать только для отображения состояния, которое зависит от другого состояния. Если вы обнаружите, что вызываете другие методы или назначаете другие свойства внутри computed, то, скорее всего, вы делаете что-то неправильно. Возьмем пример:

export default {
  data() {
    return {
      array: [1, 2, 3]
    };
  },
  computed: {
    reversedArray() {
      return this.array.reverse(); // SIDE EFFECT - mutates a data property
    }
  }
};

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

original array: [ 3, 2, 1 ] computed array: [ 3, 2, 1 ]

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

Предположим, у нас есть компонент, который отображает подробную информацию о цене заказа.

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.

<price-details :order="order"
      @total-change="totalChange">
</price-details>
export default {
  // other properties which are not relevant in this example
  methods: {
    totalChange(grandTotal) {
      if (this.isSpecialCustomer) {
        this.order = {
          ...this.order,
          discount: this.order.discount + 0.1
        };
      }
    }
  }
};

Теперь предположим, что в очень редком случае, мы хотим предоставить кому то из наших клиентов дополнительную скидку 10%. У нас может возникнуть соблазн изменить заказ и увеличить его скидку.

Однако это приведет к довольно серьезной ошибке.

Антипаттерны vue.js

Антипаттерны vue.js

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

Вы могли подумать, что в реальном приложении сделать такую ошибку невозможно, но так ли это? Наш сценарий (если бы это произошло) было бы очень сложно отладить или отследить, потому что он требует «особого клиента», который может появляться только один раз на каждые 1000 заказов.

Мутация вложенных props

Иногда может возникнуть соблазн отредактировать свойство в props, которое является объектом или массивом, потому что это «легко» сделать. Но разве это лучше всего? Давайте посмотрим на пример

<template>
  <div class="hello">
    <div>Name: {{product.name}}</div>
    <div>Price: {{product.price}}</div>
    <div>Stock: {{product.stock}}</div>

    <button @click="addToCart" :disabled="product.stock <= 0">Add to card</button>
  </div>
</template>

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. Это легко сделать. Однако это может создать несколько проблем:

  • Мы изменяем props, не сообщая об этом родительскому компоненту.
  • Из-за этого мы можем получить неожиданное поведение или, что еще хуже, странные ошибки.
  • Мы вводим некоторую логику в компонент продукта, которой, вероятно, не должно быть

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

<template>
   <Product :product="product" @add-to-cart="addProductToCart(product)"></Product>
</template>
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--;
      }
    }
  }
};

У разработчика может появиться мысль. Что ж, я должен уменьшить запас внутри метода addProductToCart. Тем самым мы вносим небольшую ошибку: теперь, если мы нажимаем кнопку, количество уменьшается на 2 вместо 1.

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

Если это вас не убеждает, давайте предположим другой сценарий. Возьмем, к примеру, пользовательскую форму. Мы передаем user в качестве prop и хотим изменить его адрес электронной почты и имя. Код ниже может показаться «правильным»

// Parent
<template>
  <div>
    <span> Email {{user.email}}</span>
    <span> Name {{user.name}}</span>
    <user-form :user="user" @submit="updateUser"/>
  </div>
</template>
 import UserForm from "./UserForm"
 export default {
  components: {UserForm},
  data() {
   return {
     user: {
      email: 'loreipsum@email.com',
      name: 'Lorem Ipsum'
     }
   }
  },
  methods: {
    updateUser() {
     // Send a request to the server and save the user
    }
  }
 }
// UserForm.vue Child
<template>
  <div>
   <input placeholder="Email" type="email" v-model="user.email"/>
   <input placeholder="Name" v-model="user.name"/>
   <button @click="$emit('submit')">Save</button>
  </div>
</template>
 export default {
  props: {
    user: {
     type: Object,
     default: () => ({})
    }
  }
 }

Можно легко повесить v-model на user. VueJS позволяет это. Так почему бы не сделать это?

  • Что делать, если у нас есть требование добавить кнопку «Отмена» и отменить введенные изменения?
  • Что, если вызов нашего сервера завершится неудачно. Как нам отменить изменения для пользователя?
  • Действительно ли мы хотим отображать измененный адрес электронной почты и имя в родительском компоненте перед сохранением этих изменений?

Простое решение может заключаться в том, чтобы клонировать пользователя перед отправкой его в качестве входных данных (props).

<user-form :user="{...user}">

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

<template>
  <div>
   <input placeholder="Email" type="email" v-model="form.email"/>
   <input placeholder="Name" v-model="form.name"/>
   <button @click="onSave">Save</button>
   <button @click="onCancel">Save</button>
  </div>
</template>
 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
        }
      }
     }
    }
  }
 }

Хотя приведенный выше код определенно кажется более подробным, он позволяет избежать описанных выше проблем. Мы следим за изменениями свойств пользователя, а затем копируем их в form в data. Это позволяет нам иметь индивидуальное состояние для формы и:

  • Отменить изменения, переназначив форму this.form = {... this.user}
  • Иметь изолированное состояние для формы
  • Не влиять на родителя, если мы не хотим
  • Контролировать, когда мы сохраняем изменения

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

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

Мы рассмотрим пример с раскрывающимся списком. Предположим, у нас есть раскрывающееся родительское и раскрывающееся дочернее меню. Когда пользователь выбирает определенный вариант, мы хотим закрыть раскрывающееся меню, которое отображается/скрывается в родительском раскрывающемся списке. Давайте посмотрим на пример

// Dropdown.vue (parent)
<template>
  <div>
    <button @click="showMenu = !showMenu">Click me</button>
    <dropdown-menu v-if="showMenu" :items="items"></dropdown-menu>
  </div>
<template>
 export default {
  props: {
   items: Array
  },
  data() {
   return {
     selectedOption: null,
     showMenu: false
   }
  }
 }
// DropdownMenu.vue (child)
<template>
  <ul>
    <li v-for="item in items" @click="selectOption(item)">{{item.name}}</li>
  </ul>
<template>
export default {
  props: {
   items: Array
  },
  methods: {
    selectOption(item) {
     this.$parent.selectedOption = item
     this.$parent.showMenu = false
    }
  }
}

Обратите внимание на метод selectOption. Хотя это случается очень редко, у некоторых людей возникнет желание получить доступ к $parent напрямую, потому что это просто. Код будет работать нормально на первый взгляд, но что, если:

  • Мы меняем свойство showMenu или selectedOption? Раскрывающийся список не закроется, и ни один из вариантов не будет выбран;
  • Мы захотим добавить переход в выпадающее меню.
// Dropdown.vue (parent)
<template>
  <div>
    <button @click="showMenu = !showMenu">Click me</button>
    <transition name="fade">
      <dropdown-menu v-if="showMenu" :items="items"></dropdown-menu>
    </dropdown-menu>
  </div>
<template>

Код снова завершится ошибкой, потому что элемент $parent изменился. Родительский элемент dropdown-menu больше не раскрывающийся компонент, а transition компонент.

Принцип Props down, events up будет правильным подходом. При данном подходе дочерний компонент должен получать от родителя данные, котороые он не может менять и список родительских событий, которые он может вызвать.

// Dropdown.vue (parent)
<template>
  <div>
    <button @click="showMenu = !showMenu">Click me</button>
    <dropdown-menu v-if="showMenu" :items="items" @select-option="onOptionSelected"></dropdown-menu>
  </div>
<template>
 export default {
  props: {
   items: Array
  },
  data() {
   return {
     selectedOption: null,
     showMenu: false
   }
  },
  methods: {
    onOptionSelected(option) {
      this.selectedOption = option
      this.showMenu = true
    }
  }
 }
// DropdownMenu.vue (child)
<template>
  <ul>
    <li v-for="item in items" @click="selectOption(item)">{{item.name}}</li>
  </ul>
</template>
 export default {
  props: {
   items: Array
  },
  methods: {
    selectOption(item) {
     this.$emit('select-option', item)
    }
  }
 }

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

Заключение

Самый короткий код - не всегда лучший, а «простые и быстрые» способы часто имеют недостатки. Каждый язык программирования, проект или фреймворк требует терпения и времени, чтобы правильно их использовать. То же самое и для Vue. Пишите код внимательно и терпеливо.