У нас в команде есть пару проектов, для которых есть старые frontend. Написаны все они на разных технологиях, но объединяет их одно: нежелание кого-либо туда лезть и что-то править. Команде там кажется страшно, непонятно и неудобно. Любая доработка превращается в головную боль. В очередном проекте нам хотелось не допустить такого развития событий, и, кажется, у нас получилось.
Данная статья предназначена не для полноценных frontend разработчиков, а для членов команд, которым требуется реализовать небольшой frontend не имея должной экспертизы в этом вопросе. И сделать это так, чтобы каждый новый сотрудник без глубокого погружения мог сразу делать небольшие доработки.
В рамках этой статьи мы построим скелет будущего frontend приложения. На основе этого скелета и наших ошибок, о которых мы рассказали далее, можно брать и пилить проект с низким порогом вхождения (опробовали на новых сотрудниках).
Мы постарались написать приложение так, чтобы backend разработчикам было наиболее привычно и комфортно. Классы, интерфейсы, наследование, типизация, вот это вот всё… И, конечно же, чтобы визуально это смотрелось красиво и современно. Для всех этих целей мы выбрали Vue и TS. Перед началом работ советую ознакомиться с документацией по vue и vue router
Итак, начнём…
1. Скелет проекта
Нам потребуются установленные Node.js
и npm
(диспетчер пакетов Node.js). Напоминаю, что безопаснее пользоваться дистрибутивами и пакетами, которые вышли раньше 24 февраля 22 года.
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install -y nodejs
Нам понадобятся:
Vue (документация тут);
TypeScript;
Axios (для запросов к серверу);
Vue Router (поддержка роутинга во Vue о которой можно почитать тут);
Vuex (позволяет общаться компонентам между собой. Можно ознакомиться тут);
CSS Pre-processors;
Linter / Formatter (анализ качества вашего кода). Например: eslint или tslint;
Пакеты vue-class-component и vue-property-decorator для того чтобы привести к классо-ориентированному виду, который все мы так любим;
UI Framework с которым мы будем работать, для того чтобы не было мучительно больно изобретать велосипеды, рисовать кнопочки и заниматься другими трудоемкими вещами. Мне приходилось работать со следующими фреймворками:
boostrap-vue (показался не удобным);
element-ui (большое разнообразие компонентов);
vuetify (достаточное количество компонентов и хорошая документация).
Для своего проекта мы выбрали element ui из-за обилия различных компонентов.
Все это можно легко поставить и развернуть при помощи vue-cli, но мы в команде выбрали другой путь. После всем нам известных событий, много библиотек стали тянуть транзитивно вредоносные зависимости. Поэтому команда приняла решение обойтись без vue-cli и использовать webpack для более очевидного управления зависимостями и более гибкой сборки проекта.
Пример получившегося package.json:
{
"name": "hello-world",
"version": "1.0.0",
"scripts": {
"build:dev": "npx webpack",
"build:prod": "npx webpack --env production",
"lint": "eslint . --ext .ts",
"lint:fix": "npm run lint -- --fix",
"serve": "npx webpack serve"
},
"dependencies": {
"axios": "0.25.0",
"element-ui": "2.15.6",
"ts-jenum": "2.2.2",
"vue": "2.6.14",
"vue-axios": "3.4.0",
"vue-cookies": "1.7.4",
"vue-router": "3.5.3",
"vuex": "3.6.2"
},
"devDependencies": {
"@babel/core": "7.17.0",
"@babel/preset-env": "7.16.11",
"@babel/preset-typescript": "7.16.7",
"@babel/runtime": "7.17.0",
"@types/webpack-env": "1.16.3",
"@typescript-eslint/eslint-plugin": "5.21.0",
"@typescript-eslint/parser": "5.21.0",
"@vue/eslint-config-typescript": "10.0.0",
"babel-loader": "8.2.3",
"babel-preset-vue": "2.0.2",
"clean-webpack-plugin": "4.0.0",
"css-loader": "6.6.0",
"eslint": "^8.14.0",
"eslint-plugin-vue": "^8.7.1",
"eslint-webpack-plugin": "^3.1.1",
"file-loader": "6.2.0",
"html-webpack-plugin": "5.5.0",
"mini-css-extract-plugin": "2.5.3",
"sass": "1.49.7",
"sass-loader": "12.4.0",
"ts-loader": "9.2.6",
"tsconfig-paths-webpack-plugin": "3.5.2",
"typescript": "4.5.5",
"url-loader": "4.1.1",
"vue-class-component": "7.2.6",
"vue-loader": "15.9.8",
"vue-property-decorator": "9.1.2",
"vue-template-compiler": "2.6.14",
"webpack": "5.68.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "4.7.4"
},
// Избавляемся от вредоносных версий. Работает только с npm > 8.3
"overrides": {
"node-ipc@>9.2.1 <10": "9.2.1",
"node-ipc@>10.1.0": "10.1.0"
}
}
Для установки всех пакетов на основе package.json достаточно выполнить команду npm i
.
Следующим шагом модифицируем конфиг для анализа нашего кода .eslintrc.js. Правила, описанные ниже, это лишь субъективное мнение автора, каждый волен настроить их под свой вкус и цвет.
.eslintrc.js
module.exports = {
root: true,
env: {
node: true
},
// Подключаем рекомендованные правила
"extends": [
"plugin:vue/recommended",
'eslint:recommended',
"@vue/typescript/recommended"
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
project: ["./tsconfig.json"],
},
// Дополняем рекомендованные правила своими
rules: {
// Отключаем вывод в консоль для прода
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
// Отключаем дебаг для прода
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// Отключаем for in для массивов
"@typescript-eslint/no-for-in-array": "warn",
// Не ставим await в return
"no-return-await": "warn",
// Никаких any
"@typescript-eslint/no-explicit-any": "warn",
// Настраиваем отступы
"indent": ["warn", 4],
// Нет лишним пробелам
"no-multi-spaces": "warn",
// Пробелы перед/после ключевых слов
"keyword-spacing": [2, {"before": true, "after": true}],
// Проверка типов при сложении
"@typescript-eslint/restrict-plus-operands": "warn",
// Сравнение через тройное равно
"eqeqeq": "warn",
// Длинна строки кода
"max-len": ["warn", { "code": 160 }],
// Предупреждаем о забытых await
"require-await": "warn",
// Предупреждаем о забытых фигурных скобках
"curly": "warn",
// Максимальное количество классов в файле
"max-classes-per-file": ["warn", 2],
// Двойные кавычки
"quotes": ["warn", "double"],
// Проверка точек с запятой
"semi": ["warn", "always"]
}
}
Для проверки кода через eslint достаточно будет выполнить код: npm run lint
.
2. Сборка проекта
Перейдём к самой ужасной части: сборке проекта на webpack. На самом деле это не так страшно, как выглядит на первый взгляд. Есть отличная документация по каждому используемому плагину. Поэтому я приложу код сборки проекта с небольшими комментариями. Актуально для webpack 5 версии.
webpack.config.js
const path = require("path");
const { DefinePlugin } = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = env => {
return {
context: path.resolve(process.cwd(), "src"),
devtool: env.production === true ? false : "eval-cheap-source-map",
mode: env.production === true ? "production" : "development",
performance: {
hints: false,
},
// Точки входа
entry: {
"main": ["./ts/main.ts"]
},
// Что получаем на выходе
output: {
path: path.resolve(process.cwd(), "dist"),
filename: "js/main.js",
publicPath: !!process.env.WEBPACK_DEV_SERVER ? "/" : "./",
},
resolve: {
plugins: [new TsconfigPathsPlugin()],
extensions: [".ts", ".js", ".vue", ".json"],
alias: {
vue$: "vue/dist/vue.esm.js"
}
},
// Dev сервер
devServer: {
devMiddleware: {
index: true,
publicPath: '/',
writeToDisk: true
},
static: {
directory: path.join(__dirname, 'dist')
},
port: 9000,
hot: true
},
module: {
rules: [
// Загрузчик TS файлов
{
test: /\.tsx?$/,
loader: "ts-loader",
exclude: /node_modules/,
options: {
appendTsSuffixTo: [/\.vue$/]
}
},
// Загрузчик vue файлов (хотя мы их не используем, но вдруг кому понадобится)
{
test: /\.vue$/,
use: "vue-loader",
},
// Загрузчик изображений
{
test: /\.(png|jpg|gif|svg|ico)$/,
loader: "file-loader",
options: {
name: "static/[name].[ext]?[hash]"
}
},
// Загрузчик js файлов
{
test: /\.js$/,
loader: "file-loader",
exclude: /node_modules/,
options: {
name: "js/[name].[ext]"
}
},
// Загрузчик стилей
{
test: /\.(css|sass|scss)$/,
use: [
// Минификатор стилей
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: (resourcePath, context) => {
return path.relative(path.dirname(resourcePath), context) + "/";
},
},
},
// Загрузчик обычных css стилей
"css-loader",
// Sass-загрузчик
{
loader: "sass-loader"
}
]
}
]
},
plugins: [
// Очищает build директорию
new CleanWebpackPlugin(),
// Формирует html. Подсовывает title, делает внедрение js в body
new HtmlWebpackPlugin({
inject: "body",
template: "index.html",
title: "Hello-world"
}),
// Запускает проверку кода через eslint
new ESLintPlugin({
extensions: "ts"
}),
new VueLoaderPlugin(),
// Минифицирует стили
new MiniCssExtractPlugin({
filename: 'static/style.css'
}),
new DefinePlugin({
__VUE_OPTIONS_API: JSON.stringify(true),
VUE_PROD_DEVTOOLS: JSON.stringify(env.production !== true),
}),
]
}
};
По итогу первых двух пунктов получаем скелет нашего проекта, который можно посмотреть тут.
3. Время собирать камни…
Не все решения, которые мы приняли в ходе разработки, были хороши. Было над чем поработать после, чтобы привести в подобающий вид. Собственно, подробнее дальше.
3.1. Дублирование кода и шаблона.
Большинство задач с переиспользованием кода во Vue решаются посредством композиции компонентов. Обычно приложение организуется в виде дерева вложенных компонентов.
Если мы видим, что у нас в другом месте намечается точно такая же функциональность, в первую очередь попробуйте выделить это в отдельный компонент.
Пример: был сложный компонент редактора документов на 2000 строк кода и на 3000 строк шаблона. В самом простом виде он выглядит так:
Пришла задача от бизнеса сделать ещё один редактор другого типа документов. Этот редактор отличается всего лишь на 25%. У нас решили эту задачу посредством наследования.
Что не так:
Компонент на 2000 строк кода и 3000 строк шаблона это уже сигнал о том, что что-то не так. При открытии такого компонента хочется плакать.
Наследование нас спасло от дублирования кода, но не от дублирования 3000 строк шаблона.
Решение: На рисунке выше можно уже увидеть, что блоки “Поля документов”, “История изменений”, “Статус документа”, “Связанные документы” и “Вложения” очень хорошо ложатся в отдельные компоненты. Всего лишь нужно вынести это всё в отдельный класс.
Если эти компоненты совпадают на 100% у обоих редакторов (Вложения, история изменений, связанные документы), то таким подходом мы избавились от дублирования и кода и шаблона для этой части.
Если компоненты отличаются немного, то можно сделать их настраиваемыми через Props.
После того, как редактор был разбит на мелкие компоненты мы получили:
Избавление от дублирования и кода и шаблона.
Маленькие и понятные компоненты, которые имеют лишь единственную обязанность. Такие компоненты легко понимать и модифицировать.
При избавлении от дублирования лучше начинать именно с разбиения на компоненты, и только если это не помогает, переходить к другим вариантам.
3.2 Используйте Single File Component
Избавляемся от .vue, .ts и .html файлов и склеиваем их в один .ts файл. “Зачем это всё?” — спросите вы. Просто данный стиль ближе по духу разработчикам, не имевшим дело с frontend. Он менее пугающий. А также это позволяет посмотреть на все ресурсы компонента сразу в одном файле. Это просто удобнее чем, открывать три разных файла.
Если вы используете vue-cli вам потребуется в vue.config.ts проставить флаг runtimeCompiler: true
.
Склеенные компоненты выглядят следующим образом:
import { Component, Vue } from 'vue-property-decorator';
@Component({
template: `
<div class="about">
<h1>This is an about page</h1>
</div>
`
})
export default class AboutView extends Vue {
// Code…
}
3.3 Не изобретайте велосипедов
Есть простые UI frameworks с кучей готовых, красивых и функциональных компонентов. Взяв на вооружение такой, можно без больших усилий реализовать почти все что потребуется, затратив минимум усилий. Да, UI будет выглядеть немного шаблонно, но функционально.
У нас встречались свои велосипеды в виде каких-то таблиц и прочего. Это приводило к ужасному визуальному виду и множеству багов. В итоге свои компоненты-таблицы были удалены и прикручены таблицы из UI framework, с небольшой кастомизацией, а восторгу от красоты новых таблиц у пользователей продукта не было предела…
3.4 Взаимодействие компонентов
Основа работы Vue — однонаправленный поток данных. Это значит, что данные из компонентов верхних уровней передаются в компоненты нижних уровней через входные параметры (или props). А для обратной связи наверх используются события (дочерние компоненты уведомляют о произошедшем событии и, возможно, передают какие-то данные). А теперь рассмотрим пример приложения со следующей структурой компонентов:
Что делать, если потребуется передать данные из дочернего 1.1.1 компонента в дочерний 2.3.1? Для этого есть два подхода:
Vuex;
Глобальная шина событий.
Рассмотрим подробнее глобальную шину событий, так как это один из самых простых способов.
Глобальная шина событий.
Данный подход позволяет передавать событие из любого компонента в любой. Реализуется это посредством создания пустого экземпляра Vue и его импорта.
export const bus = new Vue();
// ComponentA.ts (импортируем шину и генерируем в неё события)
import { bus } from "bus.js";
bus.$emit("my-event"[, данные]);
// ComponentB.ts (импортируем шину и отслеживаем в ней события)
import { bus } from "bus.js";
bus.$on("my-event", this.myEventHandler);
3.5. Динамические компоненты.
Если у вас есть одна сущность, но отображать эту сущность нужно через разные компоненты, не надо делать для этого разные роуты. Это может привести к интересным последствиям.
Пример: Был компонент редактора документов. Пришел бизнес и сказал, что некоторые документы нужно отображать в другом редакторе. Реализовано это было посредством разных ссылок. По одной ссылке (editor/101) открывался один компонент редактора документов. По другой ссылке (another_editor/102) открывался другой компонент редактора документов.
Проблемы: пользователи часто из одного редактора (editor/101) меняли в адресе номер документа на другой документ (у них был в наличии нужный им номер документа) и получали не тот редактор, который должен был открыться. Серверная часть, конечно, валидировала это недоразумение, но ситуация для пользователя неприятная.
Решение: Указываем роут на компонент, который будет отвечать за выбор нужного редактора на основе какого-либо признака (рабочий пример добавил вместе со скелетом приложения тут)
@Component({
template: `
<component v-if="document"
:key="document.id"
:is="component"
:document="document"></component>
`
})
export default class DocumentEditor extends Vue {
/** Документ */
private document: Document | null = null;
. . .
/**
* Возвращает компонент, который требуется показать клиенту,
* на основе типа документа или какого-либо другого признака
*/
private get component(): VueClass<Vue> {
if (this.document?.type === "TYPE_ONE") {
return AnotherEditor;
}
return CommonEditor;
}
}
3.6. Глобальный обработчик событий.
Чтобы отобразить пользователю ошибку, по всему проекту встречались вот такие куски кода при каждом обращении к серверу (и не только):
try {
...
} catch (e) {
this.$notify({
title: "Ошибка",
type: "error",
message: e.message
});
throw e;
}
Все поведение этого кода сводится к тому что мы отлавливаем ошибку и отображаем её пользователю. Но можно добавить глобальный обработчик ошибок, который именно этим и будет заниматься.
/**
* Глобальный обработчик ошибок Vue
*/
Vue.config.errorHandler = (err: Error & AxiosError, vm, info) => {
Notification.error(getErrorMessage(err))
}
/**
* Глобальный обработчик ошибок для промисов
*/
window.addEventListener("unhandledrejection", (event) => {
Notification.error(getErrorMessage(event.reason));
});
/**
* Извлекает сообщение об ошибке
* @param error ошибка
*/
function getErrorMessage(error: Error & AxiosError) {
return error.response?.data?.message ? error.response?.data?.message : error.message;
}
Больше не надо будет для этого добавлять блоки try ... catch(e) …
, ведь теперь глобальный обработчик сам отловит любую ошибку и отобразит её пользователю. Приятным бонусом является то, что теперь можно отобразить пользователю текст с ошибкой просто кинув эту самую ошибку throw new Error("Не заполнен номер документа");
.
Выводы:
Реализовать хороший и не сложный frontend под силу не профильным разработчикам. При всем при этом можно реализовать его так, чтобы разработчики не впадали в фрустрацию при каждой следующей доработке. Даже больше, сейчас некоторые коллеги по команде охотно берут задачи на доработки frontend.
Пример скелета приложения тут.