Много свойств или свойство-объект: критерии выбора


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


В ситуации, когда компонент требует множество данных, можно применить несколько способов их передачи. Рассмотрим их.


Набор свойств


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


Шаблон


<template>
  <div>
    <div>First name: {{ firstName }}</div>
    <div>Last name: {{ lastName }}</div>
    <div>Birth year: {{ birthYear }}</div>
  </div>
</template>

Скрипт


const MIN_BIRTH_YEAR = 1900
export default {
  name: 'PersonInfo',
  props: {
    firstName: {
      type: String,
      required: true,
      validator: firstName => firstName !== ''
    },
    lastName: {
      type: String,
      required: true,
      validator: lastName => lastName !== ''
    },
    birthYear: {
      type: Number,
      required: true,
      validator: year => year > MIN_BIRTH_YEAR && year < new Date().getFullYear()
    }
  }
}

Посмотрим на использование этого компонента


  <!-- Other part of html template-->
  <PersonInfo
    first-name="Jill"
    last-name="Smith"
    :birth-year="2000"
  />
  <!-- Other part of html template-->

Рассмотрим преимущества и недостатки такого подхода


Преимущества


  • Все свойства — независимы. При невалидности одного из значений — сообщение об ошибке будет более точным
  • Наглядно содержание передаваемых свойств
  • "Плоское лучше вложенного"
  • Добавление новых необязательных свойств довольно легкое дело: просто добавляем свойство, которое использует параметр default

props: {
  firstName: {
    type: String,
    required: true,
  },
  lastName: {
    type: String,
    required: true,
  },
  birthYear: {
    type: Number,
    required: true,
    validator: year => year > MIN_BIRTH_YEAR && year < new Date().getFullYear()
  },
  city: {
    type: String,
    default: 'New York'
  }
}

Недостатки


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

  <!-- Other part of html template-->
  <PersonInfo
    :first-name="person.firstName"
    :last-name="person.lastName"
    :birth-year="person.birthYear"
  />
  <!-- Other part of html template-->

  • Многословность в определении свойств (в сравнении с описанием объекта)

Свойство-объект


Существуют ситуации, когда атомарными являются данные не примитивных типов. В заданном примере такими данными может быть объект person.


Рассмотрим пример:


Шаблон


<template>
  <div>
    <div>First name: {{ person.firstName }}</div>
    <div>Last name: {{ person.lastName }}</div>
    <div>Birth year: {{ person.birthYear }}</div>
  </div>
</template>

Скрипт


import quartet from 'quartet' // npm validation package
const v = quartet()

const MIN_BIRTH_YEAR = 1900
export default {
  name: 'PersonInfo',
  props: {
    person: {
      type: Object,
      required: true,
      validator: v({
        firstName: 'string',
        lastName: 'string',
        birthYear: v.and(
          'safe-integer',
          v.min(MIN_BIRTH_YEAR),
          v.max(new Date().getFullYear())
        )
      })
    }
  }
}

Посмотрим на использование:


  <!-- Other part of html template-->
  <PersonInfo :person="person"/>
  <!-- or (bad) -->
  <PersonInfo :person="{ firstName: 'Jill', lastName: 'Smith', birthYear: 2000 }"/>
  <!-- Other part of html template-->

Рассмотрим преимущества и недостатки


Преимущества


  • Код в родительском компоненте становится короче
  • При наличии определённой структуры данных, которая не меняется код становится менее избыточным

Недостатки


  • Все значения становятся связанными одним объектом. При невалидности одного из значений — сообщение об ошибке будет говорить о невалидности всего объекта
  • При использовании объекта в родительском компоненте: содержание передаваемых данных скрывается за абстракцией этого объекта
  • Дополнительный уровень вложенности в компоненте
  • Добавление новых необязательных свойств со значениями внутрь объекта невозможно (не знаю как это сделать)
  • Для валидации объекта в той же степени, нужно использовать дополнительные инструменты валидации (напр. библиотеку валидации quartet)

Выводы


Я пришел к таким выводам:


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

P. S


Буду рад узнать ваши критерии выбора. Какой подход вы используете и почему? В прочем это и есть основная цель написания этой статьи. Может кто из вас знает лучшие практики и их обоснование? Спасибо, что уделили время.


Update 19:26, 16.01.2019


Также существует третий вариант c v-bind. Смотри обсуждение здесь

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


  1. Kryger
    16.01.2019 17:46

    Оба способа хороши, я бы даже сказал у каждого своя фишечка, которая удобна в определенной задаче. Первый, возможность использовать как динамические так и статические свойства одновременно. Второй способ конечно предпочтительнее, он просто читабельнее, если одно свойство изменилось, весь объект пересчитается, недостатки вполне можно обойти тем или иным путем, идеально подходит для графиков.


  1. Smekalin
    16.01.2019 20:10

    Почему не был рассмотрен вариант c v-bind='person'? При такой записи объект «разворачивается» и пропсы в компоненте будут определяться как в первом случае. Или Вы видите проблемы такого подхода?


    1. kila_vat Автор
      16.01.2019 20:24

      Справедливо, нужно было и этот вариант рассмотреть.


      Мне он показался запутывающим в моменте, например:


      <PersonInfo
        v-bind="person"
        first-name="AnotherName"
      >

      В данном примере не очевидно, какое значение будет восприниматься как значение пропсы firstName


      1. Smekalin
        17.01.2019 22:00

        Если от использования v-bind='obj' останавливает только это, то могу сказать, что явно описанные пропсы (:prop-name='name') переопределяют неявно описанные (которые из v-bind). Нашел тест для проверки этого дела


        1. kila_vat Автор
          17.01.2019 22:24

          v-bind — хорош, особенно в случаях, когда нет такого перекрытия. Я не говорю, что он не подходит для этих целей.
          Я о том, что наличие необходимости задумываться о взаимодействии двух способов задания пропсов — не самое лучшее дело, и иногда его использование приводит к неприятным ситуациям: например, разработчик, может добавлять или изменять поля в объекте — и по невнимательности — не заметить, что оно присутствует на компоненте в явном виде.
          Это потенциальная проблема, которая может и не возникнуть. В то время как преимущества v-bind — вполне актуальны.
          Я с вами согласен.


          1. Smekalin
            17.01.2019 22:29

            Согласен. Как говорится в дзене Python: «Explicit is better than implicit.»


  1. justboris
    17.01.2019 00:52

    В случае PersonInfo лучше передавать объект. Скорее всего, этот объект будет соответствовать какому-то ответу бекенда, набор полей в котором может меняться в течение разработки. Обновлять все промежуточные участки кода с участием person может оказаться запарно.


    Но есть и случаи, когда отдельные поля работают лучше, например:


    <FormField 
       label="Enter login"
       name="login"
       :value="value"
    />

    В этой ситуации все три свойства имеют разное назначение, поэтому собирать их в объект смысла нет.


    1. kila_vat Автор
      17.01.2019 01:11
      +1

      Абсолютно согласен, я ставлю вопрос так: являются ли атомарными данные, которые передаются пропсами — если их можно разделить, или если они считаются не частью чего-то целого — гораздо более логично использользовать их как разные свойства — а не одно.


  1. Dmi3ii
    17.01.2019 08:36

    Очепятка: определили свойство lastName, заполняете: second-name=«Smith»


    1. kila_vat Автор
      17.01.2019 09:18

      Спасибо, исправил


  1. PaulMaly
    17.01.2019 09:00

    Не очень понятно почему тема была рассмотрена однобоко. Лишь со стороны «удобства» разработчика. При этом совершенно не был раскрыт вопрос эффективности обсчёта изменений примитивных и сложных типов. И другие немаловажные аспекты, которые зачастую влияют на выбор подхода.


    1. kila_vat Автор
      17.01.2019 09:23

      О, это вполне понятно и просто: я просто не знаю этих тонкостей)), я в PS написал, что меня интересует ваше мнение. Я написал то, что думал и к чему на данный момент пришел по этому вопросу. Поэтому спасибо за комментарий, именно такие комментарии я и хотел увидеть.
      Если вас не затруднит, был бы рад узнать:


      • Есть ли какие-то материалы или статьи, раскрывающие недостающие в этой статье части?
      • не является это дело преждевременной оптимизацией? Случаются ли случаи, когда этот аспект становится узким местом производительности?
      • какие критерии вы используете?


      1. PaulMaly
        17.01.2019 09:41

        Все ограничения фреймворков так или иначе берут корни из особенностей JS. Вот вам пример на подумать:

        let a = 'Hello';
        let b = a;
        
        a = 'world';
        
        console.log(a === b); // ?
        
        let o1 = { a: 'Hello' };
        let o2 = o1;
        
        o1.a = 'world';
        
        console.log(o1 === o2); // ?
        


        Не зря люди иммутабильность придумали.


        1. kila_vat Автор
          17.01.2019 09:58

          Все ограничения фреймворков так или иначе берут корни из особенностей JS.

          Абсолютно согласен.


          вам пример на подумать

          Спасибо, за пример. Базовый пример, который демонстрирует, что при сравнении значений с типом 'object', сравниваются не сами объекты — а их ссылки, равно как при присваивании — копируются не объекты, а ссылки на них.


          Я правильно понял, что этот пример вы привели, чтобы показать, что это не тривиальное дело — узнать изменился объект или нет. И из этого следует, что использование объектов — уменьшает производительность? Если да, то интересно было бы узнать на сколько данное замедление значительно. Часто ли эта часть всего приложения является bottleneck'ом.


          Не зря люди иммутабильность придумали.

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


          1. PaulMaly
            17.01.2019 13:33

            И из этого следует, что использование объектов — уменьшает производительность? Если да, то интересно было бы узнать на сколько данное замедление значительно. Часто ли эта часть всего приложения является bottleneck'ом.

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

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

            Скорее нет, чем да. Просто иммутабильность — это единственный способ понять, изменялся ли объекта вообще. Представьте:

            function createUpdate(_data) {
                return function (data) {
                      if (_data !== data) {
                          recalc(data);
                          _data = data;
                      }
                };
            }
            
            let a = 'Hello';
            const update1 = createUpdate(a);
            update1(a); // не будет пересчитывать ничего если значение не изменилось
            
            let o = { a: 'Hello' };
            const update2 = createUpdate(o);
            o.a = 'world';
            update2(o); // не будет пересчитывать ничего НИКОГДА
            


            Но если мы не используем иммутабильность, но все равно хотим пересчитывать объект?

            function createUpdate(_data) {
                return function (data) {
                      if (_data !== data || typeof data === 'object') {
                          recalc(data);
                          _data = data;
                      }
                };
            }
            
            let o = { a: 'Hello' };
            const update3 = createUpdate(o);
            update3(o); // будет пересчитывать ВСЕ каждый раз, даже если ничего не изменилось
            


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

            let o2 = { ...o, a: 'world' }; // иммутабильность
            update2(o2); // пересчитается, но при этом проверка идет по всему объекту
            
            let { a } = o;
            update1(a); // наилучший вариант, минимум проверок и пересчетов
            


            p/s Примеры очень общие. В зависимости от фреймворка, что-то может работать лучше/хуже, но суть от этого сильно не меняется. Так или иначе, плоские структуры не зря советуют использовать.


            1. kila_vat Автор
              17.01.2019 14:51

              Выглядит так, что ситуации, имеющие такие масштабы, на которых встречаются проблемы с производительностью, связанные именно с уптореблением объектов, вместо примитивов — довольно редки.
              Имею в виду, если для 95% задач нам достаточно скорости обусловленной использованием объектов — нам нет необходимости обращать внимание на такие оптимизации. А значит, что вопрос производительности — важен… но важен только в 5% случаев.


              В то время как удобство разработки и поддержки — становится основным критерием выбора.


          1. PaulMaly
            17.01.2019 13:47

            создание каждый раз новых объектов при изменении старых — это затратное дело.

            Отдельно по поводу этого мифа:

            let o = {
                a: 'Hello',
                oo: { a: 'something' }
            };
            
            let o2 = { ...o, a: 'world' };
            
            console.log(o2 === o); // ?
            console.log(o2.oo === o.oo); // ?
            


            Вообще конечно, смотря как писать. Если прям наворочить, но может быть заметно затратнее. Но если использовать простую иммутабильность, то затраты на копирование примитивов не слишком велеки.


            1. kila_vat Автор
              17.01.2019 14:39

              1) "создание новых объектов при изменении старых — затратное дело" — это миф
              2) затраты на копирование примитивов не слишком велики


              Логическое противоречие.


              Вы считаете, что


              const oldObject = { a: 1, b: 2, c: { d: 3 }}
              const newObject = { ...oldObject, c: { ...oldObject.c }, a: 2 }

              будет выполнятся со сравнимой скоростью c таким подходом:


              const oldObject = { a: 1, b: 2, c: { d: 3 }}
              oldObject.a = 2

              ?


              1. PaulMaly
                17.01.2019 15:14

                Ваши примеры не эквивалентны. Возникло ощущение, что вы поленились проверить мой пример в консоле. Правильно будет так:

                const oldObject = { a: 1, b: 2, c: { d: 3 }};
                const newObject = { ...oldObject, a: 2 };
                
                // vs
                
                const oldObject = { a: 1, b: 2, c: { d: 3 }};
                oldObject.a = 2
                


                По скорости операция не очень затратная:
                1) Создаем объект
                2) Копируем 2 числа и 1 ссылку
                3) Меняем значение одно из числовых полей

                При этом профит то того, что для неизмененного объекта не будут проводится операции вычисления, огромен.

                Еще раз, если вы мутируете исходный объект, то ваш фреймворк каждый раз будет проверять все поля объекта, даже если ни одно из них не изменилось. Более того, он вынужден будет это делать и для всех вложенных объектов, и для всех вложенных-вложенных-вложенных объектов до самого конца.

                Приведу более сложный пример (псевдо-код):

                
                function createMutableUpdate(_data) {
                    return function update(data) {
                          if (_data !== data || typeof data === 'object') {
                              if (typeof data === 'object') {
                                   Object.keys(data).forEach(k => update(data[k])));
                              }
                              recalc(data);
                              _data = data;
                          }
                    };
                }
                
                function createImmutableUpdate(_data) {
                    return function update(data) {
                          if (_data !== data) {
                              if (typeof data === 'object') {
                                   Object.keys(data).forEach(k => update(data[k])));
                              }
                              recalc(data);
                              _data = data;
                          }
                    };
                }
                
                let obj = { a: 1, b: 2, c: { d: { e: 3 }, f: 4 }};
                
                const mutable = createMutableUpdate(obj);
                const immutable = createImmutableUpdate(obj);
                
                mutable(obj); // будет чекать сам объект и все его поля, вложенный объект и все его поля, и так далее до низа. при этом что НИЧЕГО не поменялось.
                
                obj.a = 5;
                mutable(obj); // будет чекать все тоже самое что и выше. при том, что изменилось ОДНО поле верхнего объекта
                
                let obj2 = obj;
                immutable(obj2); // не будет чекать ничего, потому что ничего не изменилось
                
                obj2 = { ...obj, a: 5 };
                immutable(obj2); // будет чекать сам объект и все его поля. вложенный объект и ниже чекать не будет
                


                Надеюсь я донес в итоге мысль. Если нет, тогда можно не продолжать дискуссию. Вы просили написать мнения плоские или вложенный структуры, я написал и подробно описал. Мое мнение — плоские структуры везде, где это возможно.


                1. kila_vat Автор
                  17.01.2019 15:21

                  Я с вами согласен. Но, мне кажется, что вопрос производительности — вторичен по отношению к вопросу удобства использования.
                  В данном случае просто так совпало, что то, что более производительно — более удобно. Но в общем случае, я бы оценивал бы насколько вопросы производительности — ощутимы, прежде чем делать их основным критерием выбора — того или иного варианта написания.
                  Спасибо за ваши комментарии.


                  1. PaulMaly
                    17.01.2019 15:50

                    Мне почему-то не кажется вот это:

                    <div>First name: {{ person.firstName }}</div>
                    <div>Last name: {{ person.lastName }}</div>
                    <div>Birth year: {{ person.birthYear }}</div>
                    


                    удобнее чем это:

                    <div>First name: {{ firstName }}</div>
                    <div>Last name: {{ lastName }}</div>
                    <div>Birth year: {{ birthYear }}</div>
                    


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

                    // personModel.js
                    const MIN_BIRTH_YEAR = 1900;
                    export default {
                        firstName: {
                          type: String,
                          required: true,
                          validator: firstName => firstName !== ''
                        },
                        lastName: {
                          type: String,
                          required: true,
                          validator: lastName => lastName !== ''
                        },
                        birthYear: {
                          type: Number,
                          required: true,
                          validator: year => year > MIN_BIRTH_YEAR && year < new Date().getFullYear()
                        }
                    };
                    


                    import personModel as props from './models/personModel.js';
                    
                    export default {
                      name: 'PersonInfo',
                      props
                    }
                    


                    Более того, это даже положительно скажется на вашем коде, потому что теперь модель пропсов для юзера доступна и в других компонентах:

                    import  { firstName, lastName } from './models/personModel.js';
                    
                    export default {
                      name: 'Navbar',
                      props: {
                           firstName,
                           lastName
                      },
                      computed: {
                          fullName() {
                               return `${this.firstName} ${this.lastName}`;
                          }
                      }
                    }
                    


                    1. kila_vat Автор
                      17.01.2019 16:38

                      Я же о том же написал, что более удобным и производительным являются отдельные свойства:


                      В данном случае просто так совпало, что то, что более производительно — более удобно

                      Почитайте выводы:


                      использование отдельных свойств — более предпочтительно.