Вероятно, 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.

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

Комментарии (5)


  1. vvovas
    12.08.2019 15:11

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

    А разработчик приложение в принципе не запускает, когда код пишет? И тестировщиков нет, судя по всему.


  1. Zenitchik
    12.08.2019 15:42

    Автор — сам Капитан Очевидность.


    1. rboots
      12.08.2019 19:14

      Автор — новичок. Советы вредные, каждый из этих «анти-паттернов» имеет свою зону применения. Второй это вообще best practice, хоть и использовать нужно осознанно, спички детям не игрушки. Вообще раньше любой программист знал, что такое передача объекта по ссылке и по значению, а сейчас компьютеры стали умнее, а люди разучились работать с объектами по ссылке, умеют только иммутабельно. Что дальше? Писать код самостоятельно станет анти-паттерном?


  1. rboots
    12.08.2019 19:00
    +1

    Мы выполняем изменение (мутацию) свойства и ничего не сообщаем об этом родительской сущности.

    Почитайте Банду Четырёх перед тем, как писать статьи. Чем меньше сущностей знают об изменениях тем лучше, это называется слабой связанностью. Мутацию свойств объектов во Vue оставили не просто так, это хорошая практика и мощный инструмент. То что предлагает автор статьи наоборот приведёт к коллапсу сложности приложения, из за создания нерелевантных абстракций.


  1. mxmvshnvsk
    13.08.2019 15:59

    Вы бы хоть проверили форматирование кода перед публикацией и его правильность, как минимум учитывая, что это однофайловые компоненты и ваша конструкция просто не запустится в реальной жизни. У Vue есть стайл гайд, если автор оригинальной статьи не озадачил себя этим, то не нужно плодить такое безобразие. Автор оригинала разбивал код на отдельные блоки (*.vue, *.js), поэтому примеры не вызывают такой боли. А автор перевода видимо с Vue не знаком вовсе, потому что хоть как-то знакомый с ним человек такое бы не пропустил.