- PVSM.RU - https://www.pvsm.ru -
Всем привет! В данной статье рассмотрим разработку фронта простенького блога на Vue с использованием всех прелестей Vue включая Vuex и Router. А также поговорим про структуру приложения и работу с контейнером и роутером.
Для начала определим этапы создания фронта для приложения (в данном случае блога):
Для начала распишем что же будет содержать наше SPA. Начинать следует непосредственно со страниц, так как это то, с чем непосредственно взаимодействует пользователь (если очень грубо округлить это аналогично тестам TDD – мы сначала описываем что должно делать приложение, как с ним будет взаимодействовать пользователь, а затем уже занимаемся реализацией).
Итак, какие будут страницы:
Если накидать прототипы страниц, то это выглядит примерно так:
Ничего не понятно, но на то это и прототип :-) Сразу поясню почему нарисовано не с помощью софтины для прототипирования: потому что на бумаге куда быстрее и проще, ну и иногда все-таки нужно руками шевелить, а то, когда доходит дело когда нужно где-то расписаться совсем грустно становится.
Исходя из полученных страниц накидываем список компонентов:
Оптимизацией компонентов будем заниматься уже по ходу реализации самих компонентов, на данном этапе это не нужно.
И наконец описав все точки взаимодействия пользователя опишем сущности нашего приложения:
Важно заметить, что описываются именно сущности бизнес-логики (БЛ), а не таблицы базы. При разработке и планировании фронт части, да и большей части бэка (исключая только слой данных), нужно оперировать именно сущностями БЛ, а не «таблицами». Причем сущности должны иметь только то, что используется в самом приложении. Задел на будущее это хорошо, но очень редко это будущее наступает, а в случае, когда заложен только используемый функционал и структура приложения расширяемая, то нет никаких проблем с добавлением функционала в будущем и нет ничего лишнего в текущий момент времени.
Переходим к созданию структуры. Выполняем в консоле следующие команды:
npm install -g @vue/cli
vue create vue-blog-habr -n -d -m npm
cd vue-blog-habr
Данные команды создают проект «vue-blog-habr» в соответствующей директории. Подробнее про vue-cli и используемые параметры можно найти тут [1].
И в итоге получаем стандартную структуру проекта:
Сразу же устанавливаем нужные нам пакеты:
npm install vue-router vuex axios bootstrap-vue sass-loader
npm install --save-dev --unsafe-perm node-sass
Регистрируем используемые модули:
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')
Правим структуру директорий проекта таким образом:
Описание директорий:
И запускаем тестовый сервер:
npm run serve
Последняя команда запускает сервер, на котором в режиме runtime применяются все правки проекта. Для доступа в браузере нужно перейти по адресу: localhost [2]:8080
В нашем приложении нет сложной логики, но тем не менее модели в любом случае нужно создать.
Для этого есть несколько причин:
Все модели складываем в соответствующую директории. Класс для статьи, выглядит так:
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);
}
}
Неожиданно, но на данном этапе реализацию самой бизнес-логики делать НЕ нужно. Нужно создать объект хранилища и сразу же разделить его на модули:
import Vue from 'vue'
import Vuex from 'vuex'
import blog from './modules/blog'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
blog,
},
})
А в самом модуле уже далее будем описывать все используемые мутации/геттеры/действия. Изначально, хранилище выглядит так:
export default {
state: {},
getters: {},
mutations: {},
actions: {},
}
Дополнительно заводим объект для работы с API через который будут проходить все запросы. На момент реализации фронт части, наличие бэкенда абсолютно не нужно, поэтому можно использовать статические данные:
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:
Исходя из спланированной ранее структуры, нам нужно создать 4 страницы: Index, Category, Article, а также 404 страницу. В директории src/pages добавляет соответствующие файлы:
Если у 404 страницы должна быть персональная страница с индивидуальным дизайном, то точку входа можно изменить подобным образом:
<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>
Файл главной страницы выглядит так:
<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 (без реализации):
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) {},
},
}
Нужно отметить несколько моментов:
После создания и наполнения самих страниц, нужно добавить соответствующие правила в роутер:
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 [3]
После реализации всех страниц мы получаем следующий список компонентов, основные:
И вспомогательные:
Так как мы используем модели, то при реализации компонентов мы можем использовать их для типизации параметров:
<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>
Пару слов про реализацию компонентов на главной:
<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:
<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 [4], ru.vuejs.org/v2/guide/mixins.html [5]
После создания нового компонента, нужно также исправить файлы страниц:
<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>
<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 и обновляется список комментариев. Код самого компонента выглядит так:
<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>
После реализации всех компонентов и страниц, реализации заглушек и добавления тестовых данных, в данном конкретном случае, фронт часть блога готова. Но как это обычно бывает на практике, это далеко не окончание проекта, ведь после завершения работ, начинаются правки :-)
Допустим нам необходимо изменить отображение статей на странице категорий: они должны отображаться в 2 столбца. Причем на главной странице должно остаться все как есть.
Добавляем в компонент ArticleItems дополнительное свойство cols которое будет содержать количество столбцов.
<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>
В вызове компонента на странице категорий добавляем нужное свойство:
<ArticleItems :items="articles" :cols="2"></ArticleItems>
Далее захотелось нам на страницу просмотра статьи, добавить ссылки на соседние статьи (вперед/назад). Для этого нужно будет добавить 2 геттера и ссылки на страницу:
<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>
И реализация самих геттеров:
...
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». Так как во всем приложении используются именованные роуты, то достаточно изменить только шаблон маршрута:
export default [
{
path: '/cat-:category_id',
name: 'Category',
component: () => import('@/pages/Category.vue'),
},
{
path: '/post-:post_id',
name: 'Article',
component: () => import('@/pages/Article.vue'),
},
];
Несмотря на то что это получилось очень простое приложение, структура сделана таким образом, чтобы можно было без проблем что-то изменить и/или добавить:
Визуально это можно представить так:
При такой схеме работу на проекте можно безболезненно разделить между тремя юнитами:
Все юниты могут работать независимо друг от друга, если предварительно обговорены все точки соприкосновения между ними (в общем если до работы над проектом было какое-никакое планирование).
Либо если как таковой команды нет, то логично идти по порядку сверху-вниз, чтобы как можно раньше было что смотреть (выкатить MVP).
Репозиторий со всеми исходниками: github.com/irpsv/vue-blog-habr [9]
Автор: rpsv
Источник [10]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/sajt/342404
Ссылки в тексте:
[1] можно найти тут: https://cli.vuejs.org/ru/
[2] localhost: http://localhost
[3] router.vuejs.org/ru/api/#опции-конструктора-router: https://router.vuejs.org/ru/api/#%D0%BE%D0%BF%D1%86%D0%B8%D0%B8-%D0%BA%D0%BE%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%82%D0%BE%D1%80%D0%B0-router
[4] ru.vuejs.org/v2/api/#extends: https://ru.vuejs.org/v2/api/#extends
[5] ru.vuejs.org/v2/guide/mixins.html: https://ru.vuejs.org/v2/guide/mixins.html
[6] vuex.vuejs.org/ru/guide: https://vuex.vuejs.org/ru/guide/
[7] cli.vuejs.org/ru/guide: https://cli.vuejs.org/ru/guide/
[8] router.vuejs.org/ru: https://router.vuejs.org/ru/
[9] github.com/irpsv/vue-blog-habr: https://github.com/irpsv/vue-blog-habr
[10] Источник: https://habr.com/ru/post/483064/?utm_source=habrahabr&utm_medium=rss&utm_campaign=483064
Нажмите здесь для печати.