Взглянем на jQuery, Vue.js, React и Elm

В этой части мы реализуем веб-клон Mac Notes app с помощью Vue.js, но на этот раз с компонентами. Как упоминалось в прошлый раз, Vue.js называет себя прогрессивной средой JavaScript, что означает, что вы изучаете и реализуете расширенные функции только по мере необходимости. Одна из таких функций - использование компонентов, которые являются отличным способом организации кода, когда приложение становится достаточно сложным.

Использование компонентов для изоляции поведения и повышения возможности повторного использования - не новая концепция программирования, но это новость для внешнего программирования. Нативные веб-компоненты для браузера были впервые анонсированы в 2011 году, но так и не были реализованы полностью (по состоянию на 2017 год). Фреймворки JavaScript подхватили слабину, предоставив немедленный способ создания сложных приложений с компонентной архитектурой. Однако при использовании компонентов приходится сталкиваться с серьезными недостатками - настройка, инструменты сборки, передача данных - все это становится сложнее по сравнению с предыдущими подходами. Давайте посмотрим, как это выглядит на практике, чтобы самостоятельно оценить компромиссы!

Обратите внимание: в этой части основное внимание уделяется самой компонентной модели Vue.js. Если вы хотите шире взглянуть на такие функции Vue.js, как базовые директивы и параметры, я бы рекомендовал начать с предыдущей части этой серии, где я создаю то же приложение, используя Vue.js без компонентов.

Установка

Процесс установки Vue.js с компонентами сильно отличается от установки jQuery и Vue.js без компонентов. Это связано с тем, что использование компонентов естественным образом позволяет писать код в отдельных файлах, а это означает, что нам понадобится процесс сборки для объединения нашего JavaScript. Обычно это требует много работы, но, к счастью, Vue.js предлагает vue-cli, инструмент командной строки для разработки проектов. Если он еще не установлен, вы можете установить vue-cli с помощью команды:

$ npm install -g @vue/cli

После того, как вы его установили, вы можете перейти в каталог, в котором хотите создать свой проект, и запустить:

$ vue create my-project

Вы должны заменить my-project любым именем, которое вы хотите, чтобы ваш конкретный проект имел. Вам будут предложены параметры настройки (вы можете выбрать настройку по умолчанию). В итоге вы получите новую папку с именем my-project со следующей структурой проекта:

.
├── node_modules/
│   └── ...
├── public/
│   ├── favicon.ico
│   └── index.html
├── src/
│   ├── assets/
│   │   └── logo.png
│   ├── components/
│   │   └── HelloWorld.vue
│   ├── App.vue
│   └── main.js
├── .gitignore
├── babel.config.js
├── package-lock.json
├── package.json
└── README.md

Уф, файлов много! К счастью, сейчас вам не нужно беспокоиться о большинстве из них (вы можете узнать больше о структуре проекта в официальном руководстве), почти вся наша работа будет находиться в каталоге src. На данный момент у вас уже есть приложение hello world, которое вы можете увидеть, введя следующие команды:

$ cd my-project
$ npm run serve

Последняя команда запустит сервер разработки и автоматически откроет в вашем браузере localhost:8080, где вы должны увидеть что-то вроде этого:

Это довольно круто, и что еще круче, так это то, что у вас уже настроена среда горячей перезагрузки. Это означает, что если вы внесете изменения в свой код, браузер по адресу localhost:8080 автоматически обновится. Место для начала внесения изменений - src/App.vue, которое сейчас выглядит так:

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
  name: 'app',
  components: {
    HelloWorld
  }
}
</script>
<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Это компонент Vue.js - единственный файл .vue, содержащий шаблон HTML в теге <template>, JavaScript в теге <script> и CSS в теге <style>. Это означает, что мы можем взять HTML и CSS из нашего шаблона приложения для заметок и использовать его вместо этого, в результате чего src/App.vue будет выглядеть так:

<template>
  <div id="app">
    <div class="toolbar">
      <button class="toolbar-button">New</button>
      <button class="toolbar-button">Delete</button>
      <input class="toolbar-search" type="text" placeholder="Search...">
    </div>
    <div class="note-container">
      <div class="note-selectors">
        <div class="note-selector active">
          <p class="note-selector-title">First note...</p>
          <p class="note-selector-timestamp">Timestamp here...</p>
        </div>
        <div class="note-selector">
          <p class="note-selector-title">Second note...</p>
          <p class="note-selector-timestamp">Timestamp here...</p>
        </div>
        <div class="note-selector">
          <p class="note-selector-title">Third note...</p>
          <p class="note-selector-timestamp">Timestamp here...</p>
        </div>
      </div>
      <div class="note-editor">
        <p class="note-editor-info">Timestamp here...</p>
        <textarea class="note-editor-input">
          First note...
          
          Note text here...
        </textarea>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'app'
};
</script>

<style>
/* RESET */
* {
  margin: 0;
  padding: 0;
  border: 0;
  outline: none;
  box-sizing: border-box;
}

/* LAYOUT */
#app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
.toolbar {
  padding: 0.5em;
}
.toolbar-button, .toolbar-search {
  padding: inherit;
  border-radius: 0.3em;
}
.toolbar-search {
  float: right;
}
.note-container {
  display: flex;
  flex: 1;
}
.note-selectors {
  flex: 0 0 13em;
}
.note-selector {
  padding: 1em;
}
.note-selector p {
  margin: 0;
}
.note-editor {
  display: flex;
  flex: 1;
  flex-direction: column;
}
.note-editor-info {
  padding: 0.5em;
  text-align: center;
}
.note-editor-input {
  display: flex;
  flex: 1;
  width: 100%;
  padding: 0 2em 0 2em;
}

/* COLORS */
* {
  color: #454545;
  background-color: #FAFAF8;
}
.toolbar {
  background-color: #DCDADC;
}
.toolbar-button {
  background-color: #FFFFFF;
}
.toolbar-button:active {
  background-color: #AAAAAA;
}
.note-selectors {
  border-right: 1px solid #DCDADC;
}
.note-selector {
  border-bottom: 1px solid #DCDADC;
}
.note-selector.active {
  background-color: #FCE18D;
}
.note-selector-title {
  background-color: inherit;
}
.note-selector-timestamp {
  color: #626262;
  background-color: inherit;
}
.note-editor-info {
  color: #DCDADC;
}

/* TYPOGRAPHY */
body {
  font-family: sans-serif;
}
.note-selector-title {
  font-weight: bold;
}
.note-selector-timestamp {
  font-size: 0.7em;
}
.note-editor, .note-editor-input {
  font-size: 0.9em;
}
</style>

Если вы посмотрите в свой браузер, вы должны увидеть наше знакомое приложение для заметок!

Рефакторинг в компоненты

Следующий шаг - разбить этот src/App.vue файл на компоненты. Вот диаграмма различных компонентов, на которые мы можем разбить это приложение:

<app>
├── <toolbar>
└── <note-container>
    ├── <note-selectors>
    │   └── <note-selector>
    │   └── <note-selector>
    │   └── <note-selector>
    └── <note-editor>

Вы, конечно, можете разбить его еще дальше (например, с отдельными кнопочными компонентами для панели инструментов), но это хорошая отправная точка. Используя эту структуру, файл src/App.vue теперь будет выглядеть так:

<template>
  <div id="app">
    <toolbar></toolbar>
    <note-container></note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

<style>
/* RESET */
* {
  margin: 0;
  padding: 0;
  border: 0;
  outline: none;
  box-sizing: border-box;
}

/* LAYOUT */
#app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

/* COLORS */
* {
  color: #454545;
  background-color: #FAFAF8;
}

/* TYPOGRAPHY */
body {
  font-family: sans-serif;
}
</style>

По сути, шаблон содержит только теги пользовательских компонентов <toolbar> и <note-container>, которые определены в JavaScript. В теге скрипта мы импортируем компоненты и регистрируем их в приложении Vue.js. Наконец, тег стиля был изменен только для включения CSS на верхнем уровне - любые стили, специфичные для компонентов, будут определены внутри самих компонентов. Давайте посмотрим на один из подкомпонентов, src/components/Toolbar.vue:

<template>
  <div class="toolbar">
    <button class="toolbar-button">New</button>
    <button class="toolbar-button">Delete</button>
    <input class="toolbar-search" type="text" placeholder="Search...">
  </div>
</template>

<script>
export default {
  name: 'toolbar'
};
</script>

<style>
/* LAYOUT */
.toolbar {
  padding: 0.5em;
}
.toolbar-button, .toolbar-search {
  padding: inherit;
  border-radius: 0.3em;
}
.toolbar-search {
  float: right;
}

/* COLORS */
.toolbar {
  background-color: #DCDADC;
}
.toolbar-button {
  background-color: #FFFFFF;
}
.toolbar-button:active {
  background-color: #AAAAAA;
}
</style>

Как видите, это то же самое, что и раньше - шаблон содержит HTML только для панели инструментов, скрипт содержит JavaScript (который сейчас мало что делает), а стиль содержит CSS только для панели инструментов.

Так что разбить вещи на компоненты - довольно простая задача. Сложнее всего работать с данными и событиями. Если вы хотите увидеть, как выглядят остальные компоненты прямо сейчас (они в значительной степени следуют одному образцу), вы можете подробно изучить их здесь.

Отображение заголовков заметок из массива заметок

Теперь, когда все разбито на компоненты, нам нужно сделать выбор, когда дело доходит до хранения и передачи данных. Для массива заметок имеет смысл, чтобы основной компонент <App> отслеживал заметки и передавал их своим потомкам. В результате шаблон и сценарий src/App.vue будут выглядеть так:

<template>
  <div id="app">
    <toolbar></toolbar>
    <note-container v-bind:notes="notes"></note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    return {
      notes: [
        {id: 1, body: "This is a first test", timestamp: Date.now()},
        {id: 2, body: "This is a second test", timestamp: Date.now()},
        {id: 3, body: "This is a third test", timestamp: Date.now()}
      ]
    };
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

(Примечание. Я больше не показываю теги <style>, потому что с этого момента они не изменятся). В теге <script> мы определяем массив notes в параметре данных, как мы это делали в предыдущем приложении Vue.js. В теге <template> мы передаем данные заметок в компонент <note-container> со строкой:

    <note-container v-bind:notes="notes"></note-container>

Здесь мы привязываем настраиваемый атрибут заметок к компоненту, который содержит массив заметок, определенный в параметре данных. Однако компонент <note-container> не отображает заметки напрямую, поэтому он должен передать их, используя аналогичную технику. Вот как выглядят src/components/NoteContainer.vue шаблон и скрипт:

<template>
  <div class="note-container">
    <note-selectors v-bind:notes="notes"></note-selectors>
    <note-editor></note-editor>
  </div>
</template>

<script>
import NoteSelectors from './NoteSelectors';
import NoteEditor from './NoteEditor';

export default {
  name: 'note-container',
  props: ['notes'],
  components: {
    NoteSelectors,
    NoteEditor
  }
};
</script>

В теге <script> мы определяем заметки как элемент массива props. Это важное различие - компонент <note-container> не имеет примечаний, определенных в параметре данных, потому что он не определяет свои собственные примечания; скорее, он получает данные заметок от своего родителя. Данные, полученные от родителя, хранятся в опции props.

Опять же, компонент <note-container> не работает с примечаниями напрямую, поэтому он должен передать его компоненту <note-selectors>, который вы видите в строке шаблона:

    <note-selectors v-bind:notes="notes"></note-selectors>

Наконец, мы переходим к <note-selectors> компоненту, который действительно использует примечания. Вот как выглядят шаблон и сценарий src/components/NoteSelectors.vue:

<template>
  <div class="note-selectors">
    <note-selector
      v-for="note in notes"
      v-bind:note="note"
      v-bind:key="note.id"
    >
    </note-selector>
  </div>
</template>

<script>
import NoteSelector from './NoteSelector';

export default {
  name: 'note-selectors',
  props: ['notes'],
  components: {
    NoteSelector
  }
};
</script>

Теперь мы можем использовать примечания и перебирать их с помощью директивы v-for, чтобы создать столько <note-selector> компонентов, сколько нам нужно. Нам нужно привязать каждый объект заметки к компоненту, а также уникальный ключ, чтобы помочь Vue.js отслеживать каждый компонент. Вот как выглядят шаблон и сценарий src/components/NoteSelector.vue:

<template>
  <div class="note-selector">
    <p class="note-selector-title">{{ note.body }}</p>
    <p class="note-selector-timestamp">{{ note.timestamp }}</p>
  </div>
</template>

<script>
export default {
  name: 'note-selector',
  props: ['note']
};
</script>

В теге <script> мы регистрируем note как prop (key используется внутри Vue.js), а тег <template> выглядит как простой шаблон Vue.js. На данный момент я использую тело заметки и необработанную временную метку в качестве заполнителей, мы рассмотрим их форматирование в следующем разделе.

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

Используйте вычисленные свойства и фильтры для сортировки и форматирования заметок

Прямо сейчас компонент <note-selector> нуждается в форматировании заголовка и отметки времени, чего мы можем добиться с помощью фильтров Vue.js. Вот как выглядят шаблон и сценарий src/components/NoteSelector.vue с фильтрами:

<template>
  <div class="note-selector">
    <p class="note-selector-title">
      {{ note.body | formatTitle }}
    </p>
    <p class="note-selector-timestamp">
      {{ note.timestamp | formatTimestamp }}
    </p>
  </div>
</template>
<script>
export default {
  name: 'note-selector',
  filters: {
    formatTitle: function(body) {
      var maxLength = 20;
      if (body.length > maxLength) {
        return body.substring(0, maxLength - 3) + '...';
      } else if (body.length === 0) {
        return "New note";
      } else {
        return body;
      }
    },
    formatTimestamp: function(timestamp) {
      return new Date(timestamp).toUTCString();
    }
  },
  props: ['note']
};
</script>

Кроме того, родительский <note-selectors> компонент должен отсортировать дочерние<note-selector> компоненты по отметкам времени. Вот как выглядят шаблон и сценарий src/components/NoteSelectors.vue:

<template>
  <div class="note-selectors">
    <note-selector
      v-for="note in transformedNotes"
      v-bind:note="note"
      v-bind:key="note.id"
    >
    </note-selector>
  </div>
</template>

<script>
import NoteSelector from './NoteSelector';

export default {
  name: 'note-selectors',
  props: ['notes'],
  computed: {
    transformedNotes: function() {
      return this.notes.slice().sort(function(a, b) {
        return b.timestamp - a.timestamp;
      });
    }
  },
  components: {
    NoteSelector
  }
};
</script>

Это довольно просто и начинает демонстрировать приятное преимущество работы с компонентами - вы можете определить, какой компонент отвечает за какое поведение, и соответствующим образом организовать свой код. Прямо сейчас у нас есть компонент <note-selector>, отвечающий за форматирование заголовка и отметки времени, и родительский компонент <note-selectors>, отвечающий за сортировку. Если когда-либо возникнет проблема, это облегчит поиск соответствующего кода и работу с ним.

Выберите заметку при нажатии на заголовок

Теперь давайте реализуем возможность выделения заметок. Щелчок по заголовку заметки должен одновременно выделить выбранную заметку слева, а также отобразить содержимое в редакторе справа. Сначала мы изменим шаблон в src/App.vue следующим образом:

<template>
  <div id="app">
    <toolbar></toolbar>
    <note-container
      v-bind:notes="notes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote"
    >
    </note-container>
  </div>
</template>

Здесь мы отправляем новое свойство и событие в компонент <note-container>. Имена могут сбивать с толку, поэтому стоит остановиться подробнее.

  • Код v-bind:selectedNote по сути создает новую опору с именем selectedNote, которая будет доступна в <note-container>. Он устанавливается равным "selectedNote", что относится к переменной, которая должна быть определена в параметре data текущего <App> компонента.
  • Код v-on:selectNote создает прослушиватель событий для компонента <note-container>. Если компонент <note-container> когда-либо генерирует событие с именем selectNote, он будет запускать любой код, равный которому он установлен, в данном случае это "selectNote", метод, который должен быть определен в параметре методов текущего <App> компонента.

Это означает, что сценарий в src/App.vue должен определять selectedNote в данных и selectNote в методах, как показано ниже:

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    var initialNotes = [
      {id: 1, body: "This is a first test", timestamp: Date.now()},
      {id: 2, body: "This is a second test", timestamp: Date.now()},
      {id: 3, body: "This is a third test", timestamp: Date.now()}
    ];
    return {
      notes: initialNotes,
      selectedNote: initialNotes[0]
    };
  },
  methods: {
    selectNote: function(note) {
      this.selectedNote = note;
    }
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

Все идет нормально. Теперь мы должны убедиться, что дочерний компонент <note-container> будет генерировать событие selectNote при каждом нажатии на селектор заметок. Вот как выглядят шаблон и сценарий src/components/NoteContainer.vue:

<template>
  <div class="note-container">
    <note-selectors
      v-bind:notes="notes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote">
    </note-selectors>
    <note-editor
      v-bind:selectedNote="selectedNote">
    </note-editor>
  </div>
</template>

<script>
import NoteSelectors from './NoteSelectors';
import NoteEditor from './NoteEditor';

export default {
  name: 'note-container',
  props: ['notes', 'selectedNote'],
  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    }
  },
  components: {
    NoteSelectors,
    NoteEditor
  }
};
</script>

Подобно компоненту <App>, компонент <note-container> должен передать свойство selectedNote и событие selectNote дочернему компоненту <note-selector>. Обратите внимание, что если компонент <note-selector> генерирует событие selectNote, он запустит эту функцию:

  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    }
  },

Эта функция генерирует событие selectNote из компонента <note-container>, на которое мы настроили прослушиватель событий в родительском элементе, на который он будет реагировать. Также обратите внимание, что в шаблоне мы отправляем свойство selectedNote в компонент <note-editor>, которому также нужна эта информация.

Теперь давайте посмотрим на шаблон и сценарий в компоненте <note-selectors> в app/components/NoteSelectors.vue, который на самом деле обрабатывает событие клика:

<template>
  <div class="note-selectors">
    <note-selector
      v-for="note in transformedNotes"
      v-bind:note="note"
      v-bind:selectedNote="selectedNote"
      v-bind:key="note.id"
      v-on:click.native="selectNote(note)"
    >
    </note-selector>
  </div>
</template>

<script>
import NoteSelector from './NoteSelector';

export default {
  name: 'note-selectors',
  props: ['notes', 'selectedNote'],
  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    }
  },
  computed: {
    transformedNotes: function() {
      return this.notes.slice().sort(function(a, b) {
        return b.timestamp - a.timestamp;
      });
    }
  },
  components: {
    NoteSelector
  }
};
</script>

Ключевая строка в шаблоне:

      v-on:click.native="selectNote(note)"

Это связывает собственное событие щелчка DOM с методом с именем selectNote, который определен в параметре методы компонента:

  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    }
  },

Это запускает цепную реакцию: пользователь щелкает <note-selector> компонент, запускает метод selectNote родительского <note-selectors> компонента, который генерирует событие 'selectNote', запускает метод selectNote родительского <note-container> компонента, который генерирует событие 'selectNote', запускает метод selectNote родительского <App> компонента, который изменяет фактические selectedNote данные. После изменения родительских данных Vue.js автоматически повторно отображает все соответствующие дочерние компоненты.

Этот стиль программирования (опускание, развитие событий, ставший популярным благодаря React) поначалу кажется излишне многословным, но это ключ к поддержанию чистоты архитектуры кода. Без него мы бы столкнулись с территорией спагетти jQuery. Причина, по которой мы можем избежать сложности, заключается в том, что каждый компонент вынужден знать только то, что ему нужно знать - дочерний компонент не манипулирует данными родителя напрямую, он просто передает сообщение родителю, сообщающее ему о том, что произошло какое-то событие.

Два последних момента, на которые стоит обратить внимание - во-первых, компонент <note-selector> должен использовать свойство selectedNote, чтобы правильно стилизовать компонент, если свойство note такое же, как свойство selectedNote. Шаблон и сценарий в src/components/NoteSelector.vue будут выглядеть следующим образом:

<template>
  <div class="note-selector" v-bind:class="{active: note === selectedNote}">
    <p class="note-selector-title">
      {{ note.body | formatTitle }}
    </p>
    <p class="note-selector-timestamp">
      {{ note.timestamp | formatTimestamp }}
    </p>
  </div>
</template>

<script>
export default {
  name: 'note-selector',
  props: ['note', 'selectedNote'],
  filters: {
    formatTitle: function(body) {
      var maxLength = 20;
      if (body.length > maxLength) {
        return body.substring(0, maxLength - 3) + '...';
      } else if (body.length === 0) {
        return "New note";
      } else {
        return body;
      }
    },
    formatTimestamp: function(timestamp) {
      return new Date(timestamp).toUTCString();
    }
  }
};
</script>

Здесь мы используем директиву v-bind:class для условного применения класса .active к div. Во-вторых, компонент <note-editor> также должен получить свойство selectedNote и соответствующим образом отображать информацию о нем. Вот шаблон и сценарий для src/components/NoteEditor.vue:

<template>
  <div class="note-editor">
    <p class="note-editor-info">
     {{ selectedNote.timestamp | formatTimestamp }}
    </p>
    <textarea class="note-editor-input"
      v-bind:value="selectedNote.body">
    </textarea>
  </div>
</template>

<script>
export default {
  name: 'note-editor',
  props: ['selectedNote']
};
</script>

Здесь мы регистрируем 'selectedNote' как опору и привязываем selectedNote.body к <textarea>. Нам также необходимо отформатировать selectedNote.timestamp - это небольшая проблема, поскольку мы специально определили formatTimestamp как фильтр в компоненте <note-selector>. Теперь нам нужно определить метод в более общем месте, чтобы несколько компонентов могли его использовать. Это можно сделать, зарегистрировав фильтр в src/main.js, прямо перед тем, как мы определим новый экземпляр Vue, как показано ниже:

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

Vue.filter('formatTimestamp', function(timestamp) {
  return new Date(timestamp).toUTCString();
});

new Vue({
  render: h => h(App)
}).$mount('#app')

Это было очень много кода, чтобы заставить эту особенность работать! Вы можете увидеть все изменения, описанные выше, в этом коммите.

Редактировать выбранную заметку при вводе редактора

Теперь нам нужно подключить компонент <note-editor> для отправки событий родительскому элементу для редактирования тела selectedNote. Давайте посмотрим на это на этот раз, начиная с дочернего элемента, идущего вверх, начиная с компонента <note-editor> в src/components/NoteEditor.vue:

<template>
  <div class="note-editor">
    <p class="note-editor-info">
      {{ selectedNote.timestamp | formatTimestamp }}
    </p>
    <textarea class="note-editor-input"
      v-bind:value="selectedNote.body"
      v-on:input="input($event)">
    </textarea>
  </div>
</template>

<script>
export default {
  name: 'note-editor',
  props: ['selectedNote'],
  methods: {
    input: function($event) {
      this.$emit('inputNoteEditor', $event.target.value);
    }
  }
};
</script>

Здесь код v-on:input="input($event)" захватывает собственное событие ввода <textarea> DOM и запускает метод input, определенный в параметре methods компонента. Код this.$emit('inputNoteEditor', $event.target.value); отправит событие 'inputNoteEditor' в родительский компонент, передав текст из собственного события DOM <textarea>. Это означает, что родительский компонент <note-container> должен быть подключен для прослушивания события 'inputNoteEditor', которое изменит код в src/components/NoteContainer.vue следующим образом:

<template>
  <div class="note-container">
    <note-selectors
      v-bind:notes="notes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote">
    </note-selectors>
    <note-editor
      v-bind:selectedNote="selectedNote"
      v-on:inputNoteEditor="inputNoteEditor">
    </note-editor>
  </div>
</template>

<script>
import NoteSelectors from './NoteSelectors';
import NoteEditor from './NoteEditor';

export default {
  name: 'note-container',
  props: ['notes', 'selectedNote'],
  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    },
    inputNoteEditor: function(body) {
      this.$emit('inputNoteEditor', body);
    }
  },
  components: {
    NoteSelectors,
    NoteEditor
  }
};
</script>

Код v-on:inputNoteEditor="inputNoteEditor" прослушивает inputNoteEditor события от дочернего <note-editor> компонента, который запускает метод inputNoteEditor, определенный в параметре methods. Код this.$emit('inputNoteEditor', body); в методе inputNoteEditor просто переместит информацию дальше к родительскому элементу. Наконец, родительский компонент получит данные и обновит selectedNote, который вы можете увидеть в src/App.vue:

<template>
  <div id="app">
    <toolbar></toolbar>
    <note-container
      v-bind:notes="notes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote"
      v-on:inputNoteEditor="updateSelectedNote"
    >
    </note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    var initialNotes = [
      {id: 1, body: "This is a first test", timestamp: Date.now()},
      {id: 2, body: "This is a second test", timestamp: Date.now()},
      {id: 3, body: "This is a third test", timestamp: Date.now()}
    ];
    return {
      notes: initialNotes,
      selectedNote: initialNotes[0]
    };
  },
  methods: {
    selectNote: function(note) {
      this.selectedNote = note;
    },
    updateSelectedNote: function(body) {
      this.selectedNote.body = body;
      this.selectedNote.timestamp = Date.now();
    }
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

Код v-on:inputNoteEditor="updateSelectedNote" прослушивает inputNoteEditor события от компонента <note-container> и соответственно запускает метод updateSelectedNote, определенный в параметре методов. Метод updateSelectedNote просто обновляет тело и метку времени selectedNote, что, наконец, автоматически запускает Vue.js для повторной визуализации всех дочерних компонентов.

Создать новую заметку с помощью кнопки

Теперь давайте реализуем возможность создания новой заметки. Нажатие на кнопку «Создать» должно создать новую заметку (новый идентификатор, без тела, текущая отметка времени). Новая заметка должна стать текущей выбранной заметкой и отображаться вверху списка селекторов заметок. Начнем с дочернего <toolbar> компонента в src/components/Toolbar.vue:

<template>
  <div class="toolbar">
    <button class="toolbar-button" v-on:click="clickNew">
      New
    </button>
    <button class="toolbar-button">Delete</button>
    <input class="toolbar-search" type="text" placeholder="Search...">
  </div>
</template>

<script>
export default {
  name: 'toolbar',
  methods: {
    clickNew: function() {
      this.$emit('clickNew');
    }
  }
};
</script>

Здесь код v-on:click="clickNew" захватывает собственное событие клика <button> DOM и запускает метод clickNew, определенный в параметре methods компонента. Код this.$emit('clickNew'); отправит событие 'clickNew' родительскому компоненту. Это означает, что родительский компонент <App> должен быть подключен для прослушивания события 'clickNew', которое изменит код в src/App.vue следующим образом:

<template>
  <div id="app">
    <toolbar v-on:clickNew="createNote"></toolbar>
    <note-container
      v-bind:notes="notes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote"
      v-on:inputNoteEditor="updateSelectedNote"
    >
    </note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    var initialNotes = [
      {id: 1, body: "This is a first test", timestamp: Date.now()},
      {id: 2, body: "This is a second test", timestamp: Date.now()},
      {id: 3, body: "This is a third test", timestamp: Date.now()}
    ];
    return {
      notes: initialNotes,
      selectedNote: initialNotes[0]
    };
  },
  methods: {
    selectNote: function(note) {
      this.selectedNote = note;
    },
    updateSelectedNote: function(body) {
      this.selectedNote.body = body;
      this.selectedNote.timestamp = Date.now();
    },
    createNote: function() {
      var newNote = {
        id: Date.now(),
        body: "",
        timestamp: Date.now()
      };
      this.notes.push(newNote);
      this.selectedNote = newNote;
    }
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

Код v-on:clickNew="createNote" прослушивает clickNew события от компонента <toolbar> и соответственно запускает метод createNote, определенный в параметре методов. Метод createNote вставляет новую пустую заметку в данные notes, а также обновляет данные selectedNote на новую заметку, которая, наконец, автоматически запускает Vue.js для повторной визуализации всех дочерних компонентов.

Удалить выбранную заметку с помощью кнопки

Подключение этой функции начинается очень похоже на новую функцию заметок. Давайте снова начнем с дочернего компонента <toolbar> в src/components/Toolbar.vue:

<template>
  <div class="toolbar">
    <button class="toolbar-button" v-on:click="clickNew">
      New
    </button>
    <button class="toolbar-button" v-on:click="clickDelete">
      Delete
    </button>
    <input class="toolbar-search" type="text" placeholder="Search...">
  </div>
</template>

<script>
export default {
  name: 'toolbar',
  methods: {
    clickNew: function() {
      this.$emit('clickNew');
    },
    clickDelete: function() {
      this.$emit('clickDelete');
    }
  }
};
</script>

Здесь код v-on:click="clickDelete" захватывает собственное событие клика <button> DOM и запускает метод clickDelete, определенный в параметре methods компонента. Код this.$emit('clickDelete'); отправит событие 'clickDelete' родительскому компоненту. Это означает, что родительский компонент <App> должен быть подключен для прослушивания события 'clickDelete', которое изменит код в src/App.vue следующим образом:

<template>
  <div id="app">
    <toolbar
      v-on:clickNew="createNote"
      v-on:clickDelete="deleteNote">
    </toolbar>
    <note-container
      v-bind:notes="notes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote"
      v-on:inputNoteEditor="updateSelectedNote"
    >
    </note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    var initialNotes = [
      {id: 1, body: "This is a first test", timestamp: Date.now()},
      {id: 2, body: "This is a second test", timestamp: Date.now()},
      {id: 3, body: "This is a third test", timestamp: Date.now()}
    ];
    return {
      notes: initialNotes,
      selectedNote: initialNotes[0]
    };
  },
  methods: {
    selectNote: function(note) {
      this.selectedNote = note;
    },
    updateSelectedNote: function(body) {
      this.selectedNote.body = body;
      this.selectedNote.timestamp = Date.now();
    },
    createNote: function() {
      var newNote = {
        id: Date.now(),
        body: "",
        timestamp: Date.now()
      };
      this.notes.push(newNote);
      this.selectedNote = newNote;
    },
    deleteNote: function() {
      var index = this.notes.indexOf(this.selectedNote);
      if (index !== -1) {
        this.notes.splice(index, 1);
        if (this.notes.length > 0) {
          this.selectedNote = this.notes[0];
        } else {
          this.selectedNote = {};
        }
      }
    }
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

Код v-on:clickDelete="deleteNote" прослушивает clickDelete события от компонента <toolbar> и соответственно запускает метод deleteNote, определенный в параметре методов. Метод deleteNote удаляет выбранную заметку из данных notes, что, наконец, автоматически запускает Vue.js для повторной визуализации всех дочерних компонентов.

Однако есть проблема - метод deleteNote также должен выбрать новую заметку вместо той, которая была удалена. Проблема в том, что выбор новой заметки означает, что нам нужно знать верхнюю часть списка преобразованных (отсортированных) заметок, который мы определили в дочернем <note-selectors> компоненте. В то время это казалось правильным решением, но теперь нам нужно будет реорганизовать этот метод, чтобы он был в родительском компоненте, а затем передать преобразованные заметки в качестве опоры соответствующим дочерним элементам. Это изменит код в src/App.vue следующим образом:

<template>
  <div id="app">
    <toolbar
      v-on:clickNew="createNote"
      v-on:clickDelete="deleteNote">
    </toolbar>
    <note-container
      v-bind:notes="notes"
      v-bind:transformedNotes="transformedNotes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote"
      v-on:inputNoteEditor="updateSelectedNote"
    >
    </note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    var initialNotes = [
      {id: 1, body: "This is a first test", timestamp: Date.now()},
      {id: 2, body: "This is a second test", timestamp: Date.now()},
      {id: 3, body: "This is a third test", timestamp: Date.now()}
    ];
    return {
      notes: initialNotes,
      selectedNote: initialNotes[0]
    };
  },
  methods: {
    selectNote: function(note) {
      this.selectedNote = note;
    },
    updateSelectedNote: function(body) {
      this.selectedNote.body = body;
      this.selectedNote.timestamp = Date.now();
    },
    createNote: function() {
      var newNote = {
        id: Date.now(),
        body: "",
        timestamp: Date.now()
      };
      this.notes.push(newNote);
      this.selectedNote = newNote;
    },
    deleteNote: function() {
      var index = this.notes.indexOf(this.selectedNote);
      if (index !== -1) {
        this.notes.splice(index, 1);
        if (this.transformedNotes.length > 0) {
          this.selectedNote = this.transformedNotes[0];
        } else {
          this.selectedNote = {};
        }
      }
    }
  },
  computed: {
    transformedNotes: function() {
      return this.notes.slice().sort(function(a, b) {
        return b.timestamp - a.timestamp;
      });
    }
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

Код в src/components/NoteContainer.vue просто передаст transformedNotes реквизиты следующим образом:

<template>
  <div class="note-container">
    <note-selectors
      v-bind:notes="notes"
      v-bind:transformedNotes="transformedNotes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote">
    </note-selectors>
    <note-editor
      v-bind:selectedNote="selectedNote"
      v-on:inputNoteEditor="inputNoteEditor">
    </note-editor>
  </div>
</template>

<script>
import NoteSelectors from './NoteSelectors';
import NoteEditor from './NoteEditor';

export default {
  name: 'note-container',
  props: ['notes', 'transformedNotes', 'selectedNote'],
  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    },
    inputNoteEditor: function(body) {
      this.$emit('inputNoteEditor', body);
    }
  },
  components: {
    NoteSelectors,
    NoteEditor
  }
};
</script>

И, наконец, код в src/components/NoteSelectors.vue больше не будет вычислять transformedNotes, а вместо этого будет использовать свойства transformedNotes следующим образом:

<template>
  <div class="note-selectors">
    <note-selector
      v-for="note in transformedNotes"
      v-bind:note="note"
      v-bind:selectedNote="selectedNote"
      v-bind:key="note.id"
      v-on:click.native="selectNote(note)"
    >
    </note-selector>
  </div>
</template>

<script>
import NoteSelector from './NoteSelector';

export default {
  name: 'note-selectors',
  props: ['notes', 'transformedNotes', 'selectedNote'],
  methods: {
    selectNote: function(note) {
      this.$emit('selectNote', note);
    }
  },
  components: {
    NoteSelector
  }
};
</script>

Последняя деталь - изменить компонент <note-editor> на рендеринг только при наличии selectedNote, поскольку теперь можно удалить все примечания. Шаблон в src/components/NoteEditor.vue изменится следующим образом:

<template>
  <div class="note-editor" v-if="selectedNote.id">
    <p class="note-editor-info">
      {{ selectedNote.timestamp | formatTimestamp }}
    </p>
    <textarea class="note-editor-input"
      v-bind:value="selectedNote.body"
      v-on:input="input($event)">
    </textarea>
  </div>
</template>

Фильтровать заметки при вводе поиска

Последняя особенность - возможность искать заметки сразу после ввода поискового запроса. Это означает, что нам нужно будет отслеживать новый фрагмент данных - поисковый текст, вводимый пользователем. Этот поисковый текст будет использоваться для фильтрации списка заметок, что означает, что он должен быть определен в компоненте <App>, поскольку это компонент, в котором хранятся заметки. Код в src/App.vue изменится следующим образом:

<template>
  <div id="app">
    <toolbar
      v-on:clickNew="createNote"
      v-on:clickDelete="deleteNote"
      v-bind:searchNoteText="searchNoteText"
      v-on:inputSearchNoteText="updateSearch"
    >
    </toolbar>
    <note-container
      v-bind:notes="notes"
      v-bind:transformedNotes="transformedNotes"
      v-bind:selectedNote="selectedNote"
      v-on:selectNote="selectNote"
      v-on:inputNoteEditor="updateSelectedNote"
    >
    </note-container>
  </div>
</template>

<script>
import Toolbar from './components/Toolbar';
import NoteContainer from './components/NoteContainer';

export default {
  name: 'app',
  data: function() {
    var initialNotes = [
      {id: 1, body: "This is a first test", timestamp: Date.now()},
      {id: 2, body: "This is a second test", timestamp: Date.now()},
      {id: 3, body: "This is a third test", timestamp: Date.now()}
    ];
    return {
      notes: initialNotes,
      selectedNote: initialNotes[0],
      searchNoteText: ""
    };
  },
  methods: {
    selectNote: function(note) {
      this.selectedNote = note;
    },
    updateSelectedNote: function(body) {
      this.selectedNote.body = body;
      this.selectedNote.timestamp = Date.now();
    },
    createNote: function() {
      var newNote = {
        id: Date.now(),
        body: "",
        timestamp: Date.now()
      };
      this.notes.push(newNote);
      this.selectedNote = newNote;
    },
    deleteNote: function() {
      var index = this.notes.indexOf(this.selectedNote);
      if (index !== -1) {
        this.notes.splice(index, 1);
        if (this.transformedNotes.length > 0) {
          this.selectedNote = this.transformedNotes[0];
        } else {
          this.selectedNote = {};
        }
      }
    },
    updateSearch: function(newSearchText) {
      this.searchNoteText = newSearchText;
      if (this.transformedNotes.length === 0) {
        this.selectedNote = {};
      } else if (this.transformedNotes.indexOf(this.selectedNote) === -1) {
        this.selectedNote = this.transformedNotes[0];
      }
    }
  },
  computed: {
    transformedNotes: function() {
      return this.notes
        .filter(function(note) {
          return note.body.toLowerCase().indexOf(this.searchNoteText.toLowerCase()) !== -1;
        }.bind(this))
        .sort(function(a, b) {
          return b.timestamp - a.timestamp;
        });
    }
  },
  components: {
    Toolbar,
    NoteContainer
  }
};
</script>

Давайте рассмотрим эти изменения по одному.

  • Код v-bind:searchNoteText="searchNoteText" отправляет данные searchNoteText компоненту <toolbar> как опору с именем searchNoteText.
  • Код v-on:inputSearchNoteText="updateSearch" прослушивает inputSearchNoteText события от компонента <toolbar> и соответственно запускает метод updateSearch, определенный в параметре методов.
  • Метод updateSearch обновляет данные searchNoteText во входном аргументе, который автоматически запускает Vue.js для повторной визуализации всех дочерних компонентов. Также необходимо обновить selectedNote, чтобы он стал верхним в transformedNotes, если это возможно.
  • Вычисленное свойство transformedNotes, которое использовалось для простой сортировки заметок, теперь отвечает как за фильтрацию, так и за сортировку заметок. Заметки фильтруются в зависимости от того, содержит ли тело данные searchNoteText.

Наконец, нам нужно подключить дочерний компонент <toolbar> в src/components/Toolbar.vue, чтобы он отвечал на нужные события и запускал их:

<template>
  <div class="toolbar">
    <button class="toolbar-button" v-on:click="clickNew">
      New
    </button>
    <button class="toolbar-button" v-on:click="clickDelete">
      Delete
    </button>
    <input class="toolbar-search" type="text" placeholder="Search..."
      v-bind:value="searchNoteText"
      v-on:input="inputSearchNoteText($event)"
    >
  </div>
</template>

<script>
export default {
  name: 'toolbar',
  props: ['searchNoteText'],
  methods: {
    clickNew: function() {
      this.$emit('clickNew');
    },
    clickDelete: function() {
      this.$emit('clickDelete');
    },
    inputSearchNoteText: function($event) {
      this.$emit('inputSearchNoteText', $event.target.value);
    }
  }
};
</script>

Как было замечено ранее, компонент <toolbar> не содержит логики - он просто получает реквизиты и отправляет сообщения родителю, когда пользователь взаимодействует с ним различными способами.

Вы можете взглянуть на финальное приложение во всей красе в этом репозитории GitHub.

Заключение

Как видите, работа с компонентами невероятно отличается от работы без них, даже при использовании одного и того же фреймворка Vue.js! Это действительно то, что Vue.js имеет в виду, когда описывает себя как прогрессивный фреймворк - вам не нужно разбираться в компонентах, чтобы начать работу с ним, что упрощает процесс начального обучения. Для крупномасштабных приложений может быть полезно изучить эту компонентную архитектуру как надежный способ организации вашего кода.

Я бы сказал, что для этого конкретного приложения для заметок компонентная архитектура Vue.js может быть излишней. Однако это спорный момент, поскольку каждое приложение продолжает усложняться по мере добавления новых функций. Тем не менее, несмотря на то, что сначала компоненты пугают, вы начинаете привыкать к повторяющимся шаблонам, и со временем они становятся достаточно простыми. Только когда ваше приложение станет настолько большим, что становится практически невозможно понять весь код сразу, вы оцените чистое разделение, которое обеспечивает компонентная архитектура.

В следующей части этой серии мы рассмотрим реализацию того же приложения для заметок с использованием React, совершенно другого фреймворка с аналогичной компонентной моделью. Будьте на связи!