JavaScript — это душа современных веб-приложений. Это — главный ингредиент фронтенд-разработки. Существуют различные JavaScript-фреймворки для создания интерфейсов веб-проектов. Vue.js — это один из таких фреймворков, который можно отнести к довольно популярным решениям.
Vue.js — это прогрессивный фреймворк, предназначенный для создания пользовательских интерфейсов. Его базовая библиотека направлена, в основном, на создание видимой части интерфейсов. В проект, основанный на Vue, при необходимости легко интегрировать и другие библиотеки. Кроме того, с помощью Vue.js и с привлечением современных инструментов и вспомогательных библиотек, можно создавать сложные одностраничные приложения.
В этом материале будет описан процесс создания простого Vue.js-приложения, предназначенного для работы с заметками о неких задачах. Вот репозиторий фронтенда проекта. Вот — репозиторий его бэкенда. Мы, по ходу дела, разберём некоторые мощные возможности Vue.js и вспомогательных инструментов.
Создание проекта
Прежде чем мы перейдём к разработке — давайте создадим и настроим базовый проект нашего приложения по управлению задачами.
- Создадим новый проект, воспользовавшись интерфейсом командной строки Vue.js 3:
vue create notes-app
- Добавим в проект файл
package.json
следующего содержания:{ "name": "notes-app", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "lint": "vue-cli-service lint" }, "dependencies": { "axios": "^0.19.1", "buefy": "^0.8.9", "core-js": "^3.4.4", "lodash": "^4.17.15", "marked": "^0.8.0", "vee-validate": "^3.2.1", "vue": "^2.6.10", "vue-router": "^3.1.3" }, "devDependencies": { "@vue/cli-plugin-babel": "^4.1.0", "@vue/cli-plugin-eslint": "^4.1.0", "@vue/cli-service": "^4.1.0", "@vue/eslint-config-prettier": "^5.0.0", "babel-eslint": "^10.0.3", "eslint": "^5.16.0", "eslint-plugin-prettier": "^3.1.1", "eslint-plugin-vue": "^5.0.0", "prettier": "^1.19.1", "vue-template-compiler": "^2.6.10" } }
- Установим зависимости, описанные в
package.json
:npm install
Теперь, после того, как база приложения готова, мы можем переходить к следующему шагу работы над ним.
Маршрутизация
Маршрутизация (routing) — это одна из замечательных возможностей современных веб-приложений. Маршрутизатор можно интегрировать в Vue.js-приложение, воспользовавшись библиотекой vue-router
. Это — официальный маршрутизатор для Vue.js-проектов. Среди его возможностей отметим следующие:
- Вложенные маршруты/представления.
- Модульная конфигурация маршрутизатора.
- Доступ к параметрам маршрута, запросам, шаблонам.
- Анимация переходов представлений на основе возможностей Vue.js.
- Удобный контроль навигации.
- Поддержка автоматической стилизации активных ссылок.
- Поддержка HTML5-API history, возможность использования URL-хэшей, автоматическое переключение в режим совместимости с IE9.
- Настраиваемое поведение прокрутки страницы.
Для реализации маршрутизации в нашем приложении создадим, в папке router
, файл index.js
. Добавим в него следующий код:
import Vue from "vue";
import VueRouter from "vue-router";
import DashboardLayout from "../layout/DashboardLayout.vue";
Vue.use(VueRouter);
const routes = [
{
path: "/home",
component: DashboardLayout,
children: [
{
path: "/notes",
name: "Notes",
component: () =>
import(/* webpackChunkName: "home" */ "../views/Home.vue")
}
]
},
{
path: "/",
redirect: { name: "Notes" }
}
];
const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes
});
export default router;
Рассмотрим объект routes
, который включает в себя описание маршрутов, поддерживаемых приложением. Здесь используются вложенные маршруты.
Объект children
содержит вложенные маршруты, которые будут показаны на странице приложения, представляющей его панель управления (файл DashboardLayout.vue
). Вот шаблон этой страницы:
<template>
<span>
<nav-bar />
<div class="container is-fluid body-content">
<router-view :key="$router.path" />
</div>
</span>
</template>
В этом коде самое важное — тег router-view
. Он играет роль контейнера, который содержит все компоненты, соответствующие выводимому маршруту.
Основы работы с компонентами
Компоненты — это базовая составляющая Vue.js-приложений. Они дают нам возможность пользоваться модульным подходом к разработке, что означает разбиение DOM страниц на несколько небольших фрагментов, которые можно многократно использовать на различных страницах.
При проектировании компонентов, для того, чтобы сделать их масштабируемыми и подходящими для повторного использования, нужно учитывать некоторые важные вещи:
- Идентифицируйте отдельный фрагмент функционала, который можно выделить из проекта в виде компонента.
- Не перегружайте компонент возможностями, не соответствующими его основному функционалу.
- Включайте в состав компонента только тот код, который будет использоваться для обеспечения его собственной работы. Например — это код, обеспечивающий работу стандартных для некоего компонента привязок данных, вроде года, пола пользователя, и так далее.
- Не добавляйте в компонент код, обеспечивающий работу с внешними по отношению к компоненту механизмами, например — с некими API.
Здесь, в качестве простого примера, можно рассмотреть навигационную панель — компонент NavBar
, содержащий только описания DOM-структур, относящихся к средствам навигации по приложению. Код компонента содержится в файле NavBar.vue
:
<template>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/home/notes">
<img align="center" src="@/assets/logo.png" width="112" height="28">
</a>
<a
role="button"
class="navbar-burger burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample"
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</a>
</div>
</nav>
</template>
Вот как этот компонент используется в DashboardLayout.vue
:
<template>
<span>
<nav-bar />
<div class="container is-fluid body-content">
<router-view :key="$router.path" />
</div>
</span>
</template>
<script>
import NavBar from "@/components/NavBar";
export default {
components: {
NavBar
}
};
</script>
<style scoped></style>
Взаимодействие компонентов
В любом веб-приложении чрезвычайно важна правильная организация потоков данных. Это позволяет эффективно манипулировать и управлять данными приложений.
При использовании компонентного подхода, при разделении разметки и кода приложения на небольшие части, перед разработчиком встаёт вопрос о том, как передавать и обрабатывать данные, используемые различными компонентами. Ответом на этот вопрос является организация взаимодействия компонентов.
Взаимодействие компонентов в Vue.js-проекте можно организовать с использованием следующих механизмов:
- Свойства (props) используются при передаче данных от родительским компонентам дочерним компонентам.
- Метод $emit() применяется при передаче данных от дочерних компонентов родительским компонентам.
- Глобальная шина событий (EventBus) используется в тех случаях, когда применяются структуры компонентов с глубокой вложенностью, или тогда, когда нужно, в глобальном масштабе приложения, организовать обмен между компонентами по модели «издатель/подписчик».
Для того чтобы разобраться с концепцией взаимодействия компонентов в Vue.js, добавим в проект два компонента:
- Компонент
Add
, который будет использоваться для добавления в систему новых задач и для редактирования существующих задач. - Компонент
NoteViewer
, предназначенный для вывода сведений об одной задаче.
Вот файл компонента Add
(Add.vue
):
<template>
<div class="container">
<div class="card note-card">
<div class="card-header">
<div class="card-header-title title">
<div class="title-content">
<p v-if="addMode">
Add Note
</p>
<p v-else>
Update Note
</p>
</div>
</div>
</div>
<div class="card-content">
<div class="columns">
<div class="column is-12">
<template>
<section>
<b-field label="Note Header">
<b-input
v-model="note.content.title"
type="input"
placeholder="Note header"
/>
</b-field>
<b-field label="Description">
<b-input
v-model="note.content.description"
type="textarea"
placeholder="Note Description"
/>
</b-field>
<div class="buttons">
<b-button class="button is-default" @click="cancelNote">
Cancel
</b-button>
<b-button
v-if="addMode"
class="button is-primary"
@click="addNote"
>
Add
</b-button>
<b-button
v-else
class="button is-primary"
@click="updateNote"
>
Update
</b-button>
</div>
</section>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
addMode: {
type: Boolean,
required: false,
default() {
return true;
}
},
note: {
type: Object,
required: false,
default() {
return {
content: {
title: "",
description: "",
isComplated: false
}
};
}
}
},
methods: {
addNote() {
this.$emit("add", this.note);
},
updateNote() {
this.$emit("update", this.note);
},
cancelNote() {
this.$emit("cancel");
}
}
};
</script>
<style></style>
Вот файл компонента NoteViewer
(NoteViewer.vue
):
<template>
<div class="container">
<div class="card note-card">
<div class="card-header">
<div class="card-header-title title">
<div class="column is-6">
<p>Created at {{ note.content.createdAt }}</p>
</div>
<div class="column is-6 ">
<div class="buttons is-pulled-right">
<button
v-show="!note.content.isCompleted"
class="button is-success is-small "
title="Mark Completed"
@click="markCompleted"
>
<b-icon pack="fas" icon="check" size="is-small" />
</button>
<button
v-show="!note.content.isCompleted"
class="button is-primary is-small"
title="Edit Note"
@click="editNote"
>
<b-icon pack="fas" icon="pen" size="is-small" />
</button>
<button
class="button is-primary is-small "
title="Delete Note"
@click="deleteNote"
>
<b-icon pack="fas" icon="trash" size="is-small" />
</button>
</div>
</div>
</div>
</div>
<div
class="card-content"
:class="note.content.isCompleted ? 'note-completed' : ''"
>
<strong>{{ note.content.title }}</strong>
<p>{{ note.content.description }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: "NoteViewer",
props: {
note: {
type: Object,
required: true
}
},
methods: {
editNote() {
this.$emit("edit", this.note);
},
deleteNote() {
this.$emit("delete", this.note);
},
markCompleted() {
this.$emit("markCompleted", this.note);
}
}
};
</script>
<style></style>
Теперь, когда компоненты созданы, изучим их разделы <script>
.
В объекте props
объявлены некоторые объекты с указанием их типов. Это — те объекты, которые мы собираемся передавать компоненту тогда, когда он будет выводиться на некоей странице приложения.
Кроме того, обратите внимание на те участки кода, где используется метод $emit()
. С его помощью дочерний компонент генерирует события, посредством которых данные передаются родительскому компоненту.
Поговорим о том, как применять в приложении компоненты Add
и NoteViewer
. Опишем в файле Home.vue
, приведённом ниже, механизмы передачи данных этим компонентам и механизмы прослушивания событий, генерируемых ими:
<template>
<div class="container">
<div class="columns">
<div class="column is-12">
<button
class="button is-primary is-small is-pulled-right"
title="Add New Note"
@click="enableAdd()"
>
<b-icon pack="fas" icon="plus" size="is-small" />
</button>
</div>
</div>
<div class="columns">
<div class="column is-12">
<note-editor
v-show="enableAddNote"
:key="enableAddNote"
@add="addNote"
@cancel="disableAdd"
/>
<div v-for="(note, index) in data" :key="index">
<note-viewer
v-show="note.viewMode"
:note="note"
@edit="editNote"
@markCompleted="markCompletedConfirm"
@delete="deleteNoteConfirm"
/>
<note-editor
v-show="!note.viewMode"
:add-mode="false"
:note="note"
@update="updateNote"
@cancel="cancelUpdate(note)"
/>
</div>
</div>
</div>
</div>
</template>
<script>
// @ is an alias to /src
// import NoteEditor from "@/components/NoteEditor.vue";
import NoteEditor from "@/components/Add.vue";
import NoteViewer from "@/components/NoteViewer.vue";
export default {
name: "Home",
components: {
// NoteEditor,
NoteEditor,
NoteViewer
},
data() {
return {
enableAddNote: false,
data: []
};
},
mounted() {
this.getNotes();
},
methods: {
enableAdd() {
this.enableAddNote = true;
},
disableAdd() {
this.enableAddNote = false;
},
async getNotes() {
this.data = [];
const data = await this.$http.get("notes/getall");
data.forEach(note => {
this.data.push({
content: note,
viewMode: true
});
});
},
async addNote(note) {
await this.$http.post("notes/create", note.content);
this.disableAdd();
await this.getNotes();
},
editNote(note) {
note.viewMode = false;
},
async updateNote(note) {
await this.$http.put(`notes/${note.content.id}`, note.content);
note.viewMode = true;
await this.getNotes();
},
cancelUpdate(note) {
note.viewMode = true;
},
markCompletedConfirm(note) {
this.$buefy.dialog.confirm({
title: "Mark Completed",
message: "Would you really like to mark the note completed?",
type: "is-warning",
hasIcon: true,
onConfirm: async () => await this.markCompleted(note)
});
},
async markCompleted(note) {
note.content.isCompleted = true;
await this.$http.put(`notes/${note.content.id}`, note.content);
await this.getNotes();
},
deleteNoteConfirm(note) {
this.$buefy.dialog.confirm({
title: "Delete note",
message: "Would you really like to delete the note?",
type: "is-danger",
hasIcon: true,
onConfirm: async () => await this.deleteNote(note)
});
},
async deleteNote(note) {
await this.$http.delete(`notes/${note.content.id}`);
await this.getNotes();
}
}
};
</script>
Теперь, если присмотреться к этому коду, можно заметить, что компонент Add
, носящий здесь имя note-editor
, применяется дважды. Один раз — для добавления заметки, второй раз — для обновления её содержимого.
Кроме того, мы многократно используем компонент NoteViewer
, представленный здесь как note-viewer
, выводя с его помощью список заметок, загруженный из базы данных, который мы перебираем с помощью атрибута v-for
.
Тут ещё стоит обратить внимание на событие @cancel
, используемое в элементе note-editor
, которое для операций Add
и Update
обрабатывается по-разному, даже несмотря на то, что эти операции реализованы на базе одного и того же компонента.
<!-- Add Task -->
<note-editor v-show="enableAddNote"
:key="enableAddNote"
@add="addNote"
@cancel="disableAdd" />
<!-- Update Task -->
<note-editor v-show="!note.viewMode"
:add-mode="false"
:note="note"
@update="updateNote"
@cancel="cancelUpdate(note)" />
Именно так можно избежать проблемы с масштабированием. Речь идёт о том, что если есть вероятность изменения реализации некоего механизма, то в такой ситуации компонент просто генерирует соответствующее событие.
При работе с компонентами мы пользуемся динамическим внедрением данных. Например — атрибутом :note
в note-viewer
.
Вот и всё. Теперь наши компоненты могут обмениваться данными.
Использование библиотеки Axios
Axios — это библиотека, основанная на промисах, предназначенная для организации взаимодействия с различными внешними сервисами.
Она обладает множеством возможностей и ориентирована на безопасную работу. Речь идёт о том, что Axios поддерживает защиту от XSRF-атак, перехватчики запросов и ответов, средства преобразования данных запросов и ответов, она поддерживает отмену запросов и многое другое.
Подключим библиотеку Axios к приложению и настроим её, сделав так, чтобы нам не приходилось бы её импортировать при каждом её использовании. Создадим, в папке axios
, файл index.js
:
import axios from "axios";
const apiHost = process.env.VUE_APP_API_HOST || "/";
let baseURL = "api";
if (apiHost) {
baseURL = `${apiHost}api`;
}
export default axios.create({ baseURL: baseURL });
В файл main.js
добавим перехватчик ответов на запросы, предназначенный для взаимодействия с внешним API. Мы будем применять перехватчик для подготовки данных, передаваемых в приложение, и для обработки ошибок.
import HTTP from "./axios";
// Добавить перехватчик ответов
HTTP.interceptors.response.use(
response => {
if (response.data instanceof Blob) {
return response.data;
}
return response.data.data || {};
},
error => {
if (error.response) {
Vue.prototype.$buefy.toast.open({
message: error.response.data.message || "Something went wrong",
type: "is-danger"
});
} else {
Vue.prototype.$buefy.toast.open({
message: "Unable to connect to server",
type: "is-danger"
});
}
return Promise.reject(error);
}
);
Vue.prototype.$http = HTTP;
Теперь добавим в main.js
глобальную переменную $http
:
import HTTP from "./axios";
Vue.prototype.$http = HTTP;
Мы сможем работать с этой переменной во всём приложении через экземпляр Vue.js.
Теперь мы готовы к выполнению запросов к API, которые могут выглядеть так:
const data = await this.$http.get("notes/getall");
Оптимизация
Представим, что наше приложение доросло до размеров, когда в его состав входят сотни компонентов и представлений.
Это повлияет на время загрузки приложения, так как весь его JavaScript-код будет загружаться в браузер за один заход. Для того чтобы оптимизировать загрузку приложения, нам нужно ответить на несколько вопросов:
- Как сделать так, чтобы компоненты и представления, которые в данный момент не используются, не загружались бы?
- Как уменьшить размер загружаемых материалов?
- Как улучшить время загрузки приложения?
В качестве ответа на эти вопросы можно предложить следующее: сразу загружать базовые структуры приложения, а компоненты и представления загружать тогда, когда они нужны. Сделаем это, воспользовавшись возможностями Webpack и внеся в настройки маршрутизатора следующие изменения:
{
path: "/notes",
name: "Notes",
component: () =>
import(/* webpackChunkName: "home" */ "../views/Home.vue")
}
// Взгляните на /* webpackChunkName: "home" */
Это позволяет создавать для конкретного маршрута отдельные фрагменты с материалами приложения (вида [view].[hash].js
), которые загружаются в ленивом режиме при посещении пользователем данного маршрута.
Упаковка проекта в контейнер Docker и развёртывание
Теперь приложение работает так, как нужно, а значит пришло время его контейнеризации. Добавим в проект следующий файл Dockerfile
:
# build stage
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG VUE_APP_API_HOST
ENV VUE_APP_API_HOST $VUE_APP_API_HOST
RUN npm run build
# production stage
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
При использовании приложения в продакшне мы размещаем его за мощным HTTP-сервером вроде Nginx. Это позволяет защитить приложение от взломов и от других атак.
Помните о переменной окружения, содержащей сведения о хосте, которую мы объявили, настраивая Axios? Вот она:
const apiHost = process.env.VUE_APP_API_HOST || "/";
Так как это — браузерное приложение, нам нужно установить и передать в приложение эту переменную во время его сборки. Сделать это очень просто, воспользовавшись опцией --build-arg
при сборке образа:
sudo docker build --build-arg VUE_APP_API_HOST=<Scheme>://<ServiceHost>:<ServicePort>/ -f Dockerfile -t vue-app-image .
Обратите внимание на то, что вам понадобится заменить <Scheme>
, <ServiceHost>
и <ServicePort>
на значения, имеющие смысл для вашего проекта.
После того, как контейнер приложения будет собран, его можно запустить:
sudo docker run -d -p 8080:80 — name vue-app vue-app-image
Итоги
Мы рассмотрели процесс разработки приложения, основанного на Vue.js, поговорили о некоторых вспомогательных средствах, затронули вопросы оптимизации производительности. Теперь с нашим приложением можно поэкспериментировать в браузере. Вот видео, демонстрирующее работу с ним.
Уважаемые читатели! На что вы посоветовали бы обратить внимание новичкам, стремящимся разрабатывать высокопроизводительные Vue.js-приложения, которые хорошо масштабируются?
Автор: ru_vds