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

в 10:30, , рубрики: javascript, vue.js, vuejs, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

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

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

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

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

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

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

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

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

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

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

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

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

Автор: ru_vds

Источник


* - обязательные к заполнению поля