Vue.js: 3 анти-паттерна +29


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



Автор материала, перевод которого мы сегодня публикуем, предлагает разобрать некоторые распространённые ошибки, совершаемые теми, кто разрабатывает приложения на 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.

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

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

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


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


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

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

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

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


Иногда у разработчика может появиться соблазн отредактировать что-то в свойстве из 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 после щелчка по кнопке. Сделать это, и правда, просто. Но если поступить именно так — можно столкнуться с несколькими проблемами:

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

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

<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--;
      }
    }
  }
};

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

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

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

// Родительский компонент
<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() {
     // Отправляем на сервер запрос на сохранение данных пользователя
    }
  }
 }
// Дочерний компонент UserForm.vue
<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: () => ({})
    }
  }
 }

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

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

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

<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
        }
      }
     }
    }
  }
 }

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

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

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


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

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

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

<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 (дочерний компонент)
<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-menu, использовав какой-нибудь переход?

// Dropdown.vue (родительский компонент)
<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 больше не является родителем dropdown-menu. Теперь родителем dropdown-menu является компонент transition.

Свойства передаются вниз по иерархии компонентов, события передаются вверх. В этих словах заключён смысл правильного подхода к решению нашей задачи. Вот наш пример, модифицированный в расчёте на использование событий.

// Dropdown.vue (родительский компонент)
<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 (дочерний компонент)
<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)
    }
  }
 }

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

Итоги


Самый короткий код не всегда является самым удачным. У методик разработки, предусматривающих «простое и быстрое» получение результатов, часто имеются недостатки. Для того чтобы правильно пользоваться любым языком программирования, библиотекой или фреймворком, нужно терпение и время. Это справедливо и для Vue.js.

Уважаемые читатели! Сталкивались ли вы на практике с неприятностями, подобными тем, о которых идёт речь в этой статье?




К сожалению, не доступен сервер mySQL