![](https://habrastorage.org/webt/sv/wv/ux/svwvuxu3tfp4xm_e46t5vv9u50m.png)
Всем привет! В данной статье рассмотрим разработку фронта простенького блога на Vue с использованием всех прелестей Vue включая Vuex и Router. А также поговорим про структуру приложения и работу с контейнером и роутером.
Для начала определим этапы создания фронта для приложения (в данном случае блога):
- планирование
- скелет приложения
- создание моделей
- реализация бизнес-логики
- добавление страниц и роутов
- добавление компонентов
- правки
1. Планирование
Для начала распишем что же будет содержать наше SPA. Начинать следует непосредственно со страниц, так как это то, с чем непосредственно взаимодействует пользователь (если очень грубо округлить это аналогично тестам TDD – мы сначала описываем что должно делать приложение, как с ним будет взаимодействовать пользователь, а затем уже занимаемся реализацией).
Итак, какие будут страницы:
- Главная – на ней будут располагаться популярные категории, последние комментарии и статьи
- Просмотр категории – список статей конкретной категории
- Просмотр новости – непосредственно содержание новости и список комментариев, и другие статьи из категории
Если накидать прототипы страниц, то это выглядит примерно так:
Несусветная красотень![](https://habrastorage.org/r/w780q1/webt/d6/ry/8a/d6ry8a-vgf-cs9q5pv0ttxpg_o0.jpeg)
![](https://habrastorage.org/webt/d6/ry/8a/d6ry8a-vgf-cs9q5pv0ttxpg_o0.jpeg)
Ничего не понятно, но на то это и прототип :-) Сразу поясню почему нарисовано не с помощью софтины для прототипирования: потому что на бумаге куда быстрее и проще, ну и иногда все-таки нужно руками шевелить, а то, когда доходит дело когда нужно где-то расписаться совсем грустно становится.
Исходя из полученных страниц накидываем список компонентов:
- Список статей
- Список категорий
- Список комментариев
- Форма для добавления комментария
Оптимизацией компонентов будем заниматься уже по ходу реализации самих компонентов, на данном этапе это не нужно.
И наконец описав все точки взаимодействия пользователя опишем сущности нашего приложения:
- Статья (заголовок, содержание, список комментариев)
- Категория (заголовок, список новостей)
- Комментарий (содержание)
Важно заметить, что описываются именно сущности бизнес-логики (БЛ), а не таблицы базы. При разработке и планировании фронт части, да и большей части бэка (исключая только слой данных), нужно оперировать именно сущностями БЛ, а не «таблицами». Причем сущности должны иметь только то, что используется в самом приложении. Задел на будущее это хорошо, но очень редко это будущее наступает, а в случае, когда заложен только используемый функционал и структура приложения расширяемая, то нет никаких проблем с добавлением функционала в будущем и нет ничего лишнего в текущий момент времени.
2. Скелет приложения
Переходим к созданию структуры. Выполняем в консоле следующие команды:
npm install -g @vue/cli
vue create vue-blog-habr -n -d -m npm
cd vue-blog-habr
Данные команды создают проект «vue-blog-habr» в соответствующей директории. Подробнее про vue-cli и используемые параметры можно найти тут.
И в итоге получаем стандартную структуру проекта:
![](https://habrastorage.org/webt/mv/rt/fs/mvrtfspsimtxg-bl7r7a-nzorxg.png)
Сразу же устанавливаем нужные нам пакеты:
npm install vue-router vuex axios bootstrap-vue sass-loader
npm install --save-dev --unsafe-perm node-sass
Регистрируем используемые модули:
src/main.js
import App from './App.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
import BootstrapVue from 'bootstrap-vue'
import store from './store'
import router from './router'
Vue.config.productionTip = false
Vue.use(VueRouter)
Vue.use(BootstrapVue)
new Vue({
store,
router,
render: h => h(App),
}).$mount('#app')
Правим структуру директорий проекта таким образом:
![](https://habrastorage.org/webt/eb/s_/pd/ebs_pdpl5nj7obmhh4g9l_efyms.png)
Описание директорий:
- api — содержит файлы отвечающие за «общение» с сервером
- assets — различные используемые статические ресурсы: картинки, иконки, ...
- components — компоненты приложения, без «страниц» используемых в роутере
- models — модели бизнес-логики, их можно насыщать функционалом предметной области, используемой во фронте
- pages — компоненты-страницы, используемые в роутере
- router — файлы маршрутизации
- services — вспомогательные сервисы, которые не относятся к бизнес-логике. Например, сервис Display, который содержит метод для получения координат элемента на странице
- store — файлы Vuex хранилища
- styles — файлы стилей
И запускаем тестовый сервер:
npm run serve
Последняя команда запускает сервер, на котором в режиме runtime применяются все правки проекта. Для доступа в браузере нужно перейти по адресу: localhost:8080
3. Создание моделей
В нашем приложении нет сложной логики, но тем не менее модели в любом случае нужно создать.
Для этого есть несколько причин:
- конкретизация объектов – любой произвольный объект, может содержать любые свойства и методы. Когда используются классы, мы знаем какие свойства и методы есть у конкретного объекта
- типизация параметров компонентов – вытекает из предыдущего: компоненты Vue контролируют тип входных свойств
- удобство – оперируя классами, мы оперируем сущностями предметной области, из-за этого код становится понятнее. Также классы предоставляют дополнительные возможности такие как get/set/static
Все модели складываем в соответствующую директории. Класс для статьи, выглядит так:
src/models/Article.js
export default class Article
{
constructor(id, title, content) {
this.id = id;
this.title = title;
this.content = content;
this.comments = [];
}
addComment(item) {
this.comments.push(item);
}
static createFrom(data) {
const {id, title, content} = data;
return new this(id, title, content);
}
}
4. Реализация бизнес-логики
Неожиданно, но на данном этапе реализацию самой бизнес-логики делать НЕ нужно. Нужно создать объект хранилища и сразу же разделить его на модули:
src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import blog from './modules/blog'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
blog,
},
})
А в самом модуле уже далее будем описывать все используемые мутации/геттеры/действия. Изначально, хранилище выглядит так:
src/store/modules/blog.js
export default {
state: {},
getters: {},
mutations: {},
actions: {},
}
Дополнительно заводим объект для работы с API через который будут проходить все запросы. На момент реализации фронт части, наличие бэкенда абсолютно не нужно, поэтому можно использовать статические данные:
src/api/index.js
import Article from '@/models/Article';
import Comment from '@/models/Comment';
import Category from '@/models/Category';
export default {
getArticles() {
const comments = this.getComments();
const items = [
{
id: 1, title: 'Статья 1', content: 'Содержание статьи 1',
},
{
id: 2, title: 'Статья 2', content: 'Содержание статьи 2',
},
{
id: 3, title: 'Статья 3', content: 'Содержание статьи 3',
},
{
id: 4, title: 'Статья 4', content: 'Содержание статьи 4',
},
{
id: 5, title: 'Статья 5', content: 'Содержание статьи 5',
},
{
id: 6, title: 'Статья 6', content: 'Содержание статьи 6',
},
];
return items.map((item) => {
const article = Article.createFrom(item);
article.comments = comments.filter((comment) => comment.article_id == article.id);
return article;
});
},
getComments() {
const items = [
{
id: 1, article_id: 1, content: 'Комментарий к статье 1',
},
];
return items.map((item) => Comment.createFrom(item))
},
getCategories() {
const items = [
{
id: 1, title: 'Новости', articles: [1,3,5],
},
{
id: 2, title: 'Спорт', articles: [2,3,4],
},
{
id: 3, title: 'Красота', articles: [],
},
];
return items.map((item) => Category.createFrom(item))
},
addComment(comment) {
if (comment) {
// отправка запроса на бэк
}
},
};
Что дает использование vuex:
- чистоту – компоненты не превращаются в God объекты, которые «слишком много знают»
- согласованность – если у вас есть несколько компонентов, которые используют одни и те же данные, то при использовании контейнера vuex при изменении данных, они (данные) обновятся во всем приложении, а не только в компоненте, который инициализировал обновление
- удобство – гораздо проще и удобнее обратиться к store, чем в каждом компоненте описывать типы параметров и/или вызов API
- тесты – хотя кто ими занимается?
5. Добавление страниц и роутов
Исходя из спланированной ранее структуры, нам нужно создать 4 страницы: Index, Category, Article, а также 404 страницу. В директории src/pages добавляет соответствующие файлы:
- Article.vue
- Category.vue
- Index.vue
- 404.vue
Если у 404 страницы должна быть персональная страница с индивидуальным дизайном, то точку входа можно изменить подобным образом:
src/App.vue
<template>
<div id="app">
<template v-if="is404">
<router-view></router-view>
</template>
<template v-else>
<Header></Header>
<main>
<b-container>
<router-view></router-view>
</b-container>
</main>
</template>
</div>
</template>
<script>
import '@/styles/index.scss';
import Header from '@/components/Header.vue';
export default {
name: 'App',
components: {
Header,
},
computed: {
is404() {
return this.$route.name === '404';
},
},
}
</script>
Файл главной страницы выглядит так:
src/pages/Index.vue
<template>
<b-row>
<b-col md="8" lg="9">
<ListItems :items="lastArticles">
<template v-slot:default="props">
<ArticleItem :item="props.item"></ArticleItem>
</template>
<template v-slot:empty>
Статьи еще пишутся :)
</template>
</ListItems>
</b-col>
<b-col md="4" lg="3">
<ListItems :items="popularCategories" v-slot="props">
<router-link :to="getCategoryRoute(props.item)">
{{ props.item.title }}
</router-link>
</ListItems>
<CommentItem v-for="(item, index) in lastComments" :key="index" :item="item"></CommentItem>
</b-col>
</b-row>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItem from '@/components/ArticleItem.vue'
import CommentItem from '@/components/CommentItem.vue'
import {
mapGetters,
} from 'vuex'
export default {
name: 'Index',
components: {
ListItems,
ArticleItem,
CommentItem,
},
data() {
return {};
},
methods: {
getCategoryRoute(item) {
return {
name: 'Category',
params: {
category_id: item.id,
},
};
},
},
computed: {
...mapGetters([
'lastArticles',
'lastComments',
'popularCategories',
]),
},
created() {
/**
* Запросы делаем к действиям (а не мутациям)
*/
this.$store.dispatch('loadArticles');
this.$store.dispatch('loadComments');
this.$store.dispatch('loadCategories');
},
}
</script>
После реализации страниц, все используемые геттеры и действия сразу же добавляем в хранилище vuex (без реализации):
src/store/modules/blog.js
export default {
state: {
articles: [],
comments: [],
categories: [],
//
activeArticle: null,
activeCategory: null,
},
getters: {
lastArticles(state) {
return [];
},
lastComments(state) {
return [];
},
popularCategories(state) {
return [];
},
activeCategoryArticles(state) {
return [];
},
},
mutations: {},
actions: {
async loadArticles({ commit, state }) {},
async loadComments({ commit, state }) {},
async loadCategories({ commit, state }) {},
async loadActiveCategory(context, id) {},
async loadActiveArticle(context, id) {},
async addComment({ commit }, payload) {},
},
}
Нужно отметить несколько моментов:
- когда нужно получить данные, обращаться нужно к state. Если после получения данных, нужно провести какие-то манипуляции с данными, то лучше создать для этого getters;
- когда нужно что-то сделать с данными, обращаться нужно к actions, а не mutations. Одно действие может включать в себя несколько мутаций и выполнять также другие манипуляции с данными кроме как запись в state и не имеют ограничения на асинхронность
- не нужно делать прямые запросы к API/Rest. При запросе всех данных через Vuex это будет гарантировать согласованное состояние всего приложения (если само хранилище сделано корректно)
- во всех ссылках и программной навигации, нужно обращаться к именованным маршрутам. Это позволит безболезненно менять сами маршруты, не редактируя ссылки и программную навигацию. Речь естественно про ссылки внутри SPA, адреса, которые не обрабатываются роутером нужно указывать как обычно
После создания и наполнения самих страниц, нужно добавить соответствующие правила в роутер:
src/router/index.js
import VueRouter from 'vue-router'
import blog from './blog'
export default new VueRouter({
mode: 'history',
routes: [
{
path: '/',
name: 'Index',
component: () => import('@/pages/Index.vue'),
},
/**
* Когда проект большой, роуты лучше выносить в отдельные файлы
* Распределенные по модулям приложения
* В данном случае это излишне и это просто демонстрация
*/
...blog,
{
path: '*',
name: '404',
component: () => import('@/pages/404.vue'),
},
]
})
Все параметры роутера можно найти в документации: router.vuejs.org/ru/api/#опции-конструктора-router
6. Добавление компонентов
После реализации всех страниц мы получаем следующий список компонентов, основные:
- CategoryItem
- ArticleItem
- CommentItem
- CommentForm
И вспомогательные:
- ListItems
- Header
Так как мы используем модели, то при реализации компонентов мы можем использовать их для типизации параметров:
src/components/ArticleItem.vue
<template>
<b-card :title="item.title" class="article-item-card">
<router-link :to="getArticleRoute" class="card-link">
Подробнее
</router-link>
</b-card>
</template>
<script>
import Article from '@/models/Article';
export default {
name: 'ArticleItem',
props: {
item: Article,
},
computed: {
getArticleRoute() {
return {
name: 'Article',
params: {
post_id: this.item.id,
},
};
},
},
}
</script>
<style>
.article-item-card {
margin-bottom: 1rem;
}
</style>
Пару слов про реализацию компонентов на главной:
src/pages/Index.vue
<template>
<b-row>
<b-col md="8" lg="9">
<ListItems :items="lastArticles">
<template v-slot:default="props">
<ArticleItem :item="props.item"></ArticleItem>
</template>
<template v-slot:empty>
Статьи еще пишутся :)
</template>
</ListItems>
</b-col>
<b-col md="4" lg="3">
<ListItems :items="popularCategories" v-slot="props">
<router-link :to="getCategoryRoute(props.item)">
{{ props.item.title }}
</router-link>
</ListItems>
<CommentItem v-for="(item, index) in lastComments" :key="index" :item="item"></CommentItem>
</b-col>
</b-row>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItem from '@/components/ArticleItem.vue'
import CommentItem from '@/components/CommentItem.vue'
import {
mapGetters,
} from 'vuex'
export default {
name: 'Index',
components: {
ListItems,
ArticleItem,
CommentItem,
},
data() {
return {};
},
methods: {
getCategoryRoute(item) {
return {
name: 'Category',
params: {
category_id: item.id,
},
};
},
},
computed: {
...mapGetters([
'lastArticles',
'lastComments',
'popularCategories',
]),
},
created() {
/**
* Запросы делаем к действиям (а не мутациям)
*/
this.$store.dispatch('loadArticles');
this.$store.dispatch('loadComments');
this.$store.dispatch('loadCategories');
},
}
</script>
На данной странице используется «оберточный» компонент ListItems. На первый взгляд он может показаться избыточным, ведь можно обойтись конструкцией v-for как сделаны комментарии, но использование слотов сильно сокращает используемый код и позволяет повторно использовать один и тот же элемент в нескольких местах.
Но если посмотреть на список статей, то он используется на двух страницах (Index и Category) с абсолютно одинаковым вызовом. В данной ситуации будет правильным решением создать компонент ArticleItems и наследовать его от ListItems:
src/components/ArticleItems.vue
<template>
<ListItems :items="items">
<template v-slot:default="props">
<ArticleItem :item="props.item"></ArticleItem>
</template>
<template v-slot:empty>
Статьи еще пишутся :)
</template>
</ListItems>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItem from '@/components/ArticleItem.vue'
export default {
name: 'ArticleItems',
components: {
ArticleItem,
ListItems,
},
extends: ListItems,
}
</script>
В данной ситуации наследование позволяет не дублировать описание параметров (свойство props), оно берется из родительского компонента. Подробнее про наследование и примеси: ru.vuejs.org/v2/api/#extends, ru.vuejs.org/v2/guide/mixins.html
После создания нового компонента, нужно также исправить файлы страниц:
src/pages/Category.vue (было)
<template>
<div>
<div v-if="category">
<h1>
{{ category.title }}
</h1>
<ListItems :items="articles">
<template v-slot:default="props">
<ArticleItem :item="props.item"></ArticleItem>
</template>
<template v-slot:empty>
Статьи еще пишутся :)
</template>
</ListItems>
</div>
<div v-else>
Категория не найдена
</div>
</div>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItem from '@/components/ArticleItem.vue'
import {
mapActions,
} from 'vuex'
export default {
name: 'Category',
components: {
ListItems,
ArticleItem,
},
computed: {
categoryId() {
return this.$route.params['category_id'] || null;
},
category() {
return this.$store.state.blog.activeCategory;
},
articles() {
return this.$store.getters.activeCategoryArticles;
},
},
methods: {
...mapActions([
'loadActiveCategory',
]),
},
mounted() {
this.loadActiveCategory(this.categoryId);
},
}
</script>
src/pages/Category.vue (стало)
<template>
<div>
<div v-if="category">
<h1>
{{ category.title }}
</h1>
<ArticleItems :items="articles"></ArticleItems>
</div>
<div v-else>
Категория не найдена
</div>
</div>
</template>
<script>
import ArticleItems from '@/components/ArticleItems.vue'
import {
mapActions,
} from 'vuex'
export default {
name: 'Category',
components: {
ArticleItems,
},
computed: {
categoryId() {
return this.$route.params['category_id'] || null;
},
category() {
return this.$store.state.blog.activeCategory;
},
articles() {
return this.$store.getters.activeCategoryArticles;
},
},
methods: {
...mapActions([
'loadActiveCategory',
]),
},
mounted() {
this.loadActiveCategory(this.categoryId);
},
}
</script>
Также стоит обратить внимание на форму добавления комментариев. Напрямую запросы в API не делаются, а выполняется действие Vuex и уже «внутри» выполняется запрос к API, обновляется нужная модель Article и обновляется список комментариев. Код самого компонента выглядит так:
src/components/CommentForm.vue
<template>
<form @submit.prevent="onSubmit">
<textarea class='form-control' v-model="content"></textarea>
<br>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
</template>
<script>
export default {
name: 'CommentForm',
props: {
articleId: Number,
},
data() {
return {
content: '',
};
},
methods: {
onSubmit() {
if (this.content) {
this.$store.dispatch('addComment', {
content: this.content,
article_id: this.articleId,
});
this.content = '';
}
},
},
}
</script>
После реализации всех компонентов и страниц, реализации заглушек и добавления тестовых данных, в данном конкретном случае, фронт часть блога готова. Но как это обычно бывает на практике, это далеко не окончание проекта, ведь после завершения работ, начинаются правки :-)
7. Правки
Допустим нам необходимо изменить отображение статей на странице категорий: они должны отображаться в 2 столбца. Причем на главной странице должно остаться все как есть.
Добавляем в компонент ArticleItems дополнительное свойство cols которое будет содержать количество столбцов.
src/components/ArticleItems.vue (стало)
<template>
<ListItems :items="items" class="row">
<template v-slot:default="props">
<b-col :cols="itemCols">
<ArticleItem :item="props.item"></ArticleItem>
</b-col>
</template>
<template v-slot:empty>
<b-col>
Статьи еще пишутся :)
</b-col>
</template>
</ListItems>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItem from '@/components/ArticleItem.vue'
export default {
name: 'ArticleItems',
components: {
ArticleItem,
ListItems,
},
extends: ListItems,
props: {
cols: {
type: Number,
default: 1,
},
},
computed: {
itemCols() {
return 12 / this.cols;
},
},
}
</script>
В вызове компонента на странице категорий добавляем нужное свойство:
src/pages/Category.vue
<ArticleItems :items="articles" :cols="2"></ArticleItems>
Далее захотелось нам на страницу просмотра статьи, добавить ссылки на соседние статьи (вперед/назад). Для этого нужно будет добавить 2 геттера и ссылки на страницу:
src/pages/Article.vue
<template>
<b-row v-if="article">
<b-col md="8" lg="9">
<h1>
{{ article.title }}
</h1>
<p class="mb-4">
{{ article.content }}
</p>
<table class="table table-bordered">
<tbody>
<tr>
<td class="w-50">
<router-link v-if="prevArticle" :to="getArticleRoute(prevArticle)">
{{ prevArticle.title }}
</router-link>
</td>
<td class="text-right">
<router-link v-if="nextArticle" :to="getArticleRoute(nextArticle)">
{{ nextArticle.title }}
</router-link>
</td>
</tr>
</tbody>
</table>
<CommentForm :articleId="article.id"></CommentForm>
<CommentItem v-for="(item, index) in article.comments" :key="index" :item="item"></CommentItem>
</b-col>
<b-col md="4" lg="3">
<CommentItem v-for="(item, index) in lastComments" :key="index" :item="item"></CommentItem>
</b-col>
</b-row>
</template>
<script>
import CommentForm from '@/components/CommentForm.vue';
import CommentItem from '@/components/CommentItem.vue';
import {
mapActions,
mapGetters,
} from 'vuex'
export default {
name: 'Article',
components: {
CommentForm,
CommentItem,
},
computed: {
...mapGetters([
'lastComments',
'nextArticle',
'prevArticle',
]),
articleId() {
return this.$route.params['post_id'] || null;
},
article() {
return this.$store.state.blog.activeArticle;
},
},
methods: {
...mapActions([
'loadComments',
'loadActiveArticle',
]),
getArticleRoute(item) {
return {
name: 'Article',
params: {
post_id: item.id,
},
};
},
},
mounted() {
this.loadComments();
this.loadActiveArticle(this.articleId);
},
watch: {
articleId(value) {
this.loadActiveArticle(value);
},
},
}
</script>
И реализация самих геттеров:
src/store/modules/blog.js
...
prevArticle(state) {
let prevItem = null;
if (state.activeArticle) {
state.articles.forEach((item, index) => {
if (item.id == state.activeArticle.id) {
prevItem = state.articles[index-1] || null;
}
});
}
return prevItem;
},
nextArticle(state) {
let nextItem = null;
if (state.activeArticle) {
state.articles.forEach((item, index) => {
if (item.id == state.activeArticle.id) {
nextItem = state.articles[index+1] || null;
}
});
}
return nextItem;
},
...
И напоследок нам нужно изменить URL для страницы статьи, с «article-123» на «post-123». Так как во всем приложении используются именованные роуты, то достаточно изменить только шаблон маршрута:
src/router/blog/index.js
export default [
{
path: '/cat-:category_id',
name: 'Category',
component: () => import('@/pages/Category.vue'),
},
{
path: '/post-:post_id',
name: 'Article',
component: () => import('@/pages/Article.vue'),
},
];
Полезная литература
P.S.
Несмотря на то что это получилось очень простое приложение, структура сделана таким образом, чтобы можно было без проблем что-то изменить и/или добавить:
- Компоненты занимаются исключительно визуалом, данные берутся из хранилища в виде моделей и не производятся прямые запросы к API.
- Модели содержат необходимую бизнес-логику и все связи между сущностями. Также НЕ обращаются к API.
- Хранилище (vuex) связующее звено всех составляющих: данные запрашиваются у API и преобразуются в модели, к которым затем получают доступ компоненты.
Визуально это можно представить так:
![](https://habrastorage.org/webt/di/qv/gh/diqvghmwyg2-ottosprcasqghi8.png)
При такой схеме работу на проекте можно безболезненно разделить между тремя юнитами:
- Мистер верстка — занимается реализацией страниц и компонентов, а также описывает какие поля должны содержать модели, и какие геттеры и действия должны быть у хранилища;
- Мистер фронт — занимается реализацией моделей бизнес-логики и Vuex-хранилища, а также роутингом и всем остальным что не касается мистера верстки;
- Мистер бэк — занимается реализацией сервисов API именно на стороне бэкэнда.
Все юниты могут работать независимо друг от друга, если предварительно обговорены все точки соприкосновения между ними (в общем если до работы над проектом было какое-никакое планирование).
Либо если как таковой команды нет, то логично идти по порядку сверху-вниз, чтобы как можно раньше было что смотреть (выкатить MVP).
Репозиторий со всеми исходниками: github.com/irpsv/vue-blog-habr
Only registered users can participate in poll. Log in, please.
Какой frontend фреймворк используете чаще всего?
74.31% Vue214
12.15% React35
6.25% Angular18
7.29% другой21
288 users voted. 63 users abstained.