- PVSM.RU - https://www.pvsm.ru -
Статья ориентированна на людей, которые уже имеют понимание работы Vue, на котором основан Nuxt, поэтому я буду заострять внимание только на специфических для Nuxt вещах. Но даже если вы не знакомы с ними, то статья даст общее представление как выглядит проект с PWA Nuxt.
Вы сможете почерпнуть полезные хаки, плагины и способы решения проблем, которые часто возникают при создании Nuxt приложений.
В этой статье я хочу поделиться как создать примитивный интернет-магазин:
Для упрощения восприятия процесса в этой статье не будет разбираться создание backend api, так как эта тема довольно объёмная и тянет на отдельную статью.
Преимущество прогрессивных (PWA) фреймворков вроде Nuxt.js в том, что:
И так начнем. Я буду использовать Node v12.16.1 и Yarn v1.22.0.
Создаем папку, открываем её вставляем package.json такого содержания и прописываем в консоли yarn install
{
"name": "habr",
"version": "1.0.0",
"private": true,
"scripts": {
"analyze": "cross-env NODE_ENV=production nuxt build --analyze",
"build": "cross-env NODE_ENV=production nuxt build",
"buildandstart": "cross-env NODE_ENV=production nuxt build && cross-env NODE_ENV=production nuxt start",
"dev": "cross-env NODE_ENV=development NUXT_PORT=3000 nuxt",
"start": "cross-env NODE_ENV=production nuxt start"
},
"dependencies": {
"@nuxtjs/axios": "5.9.5",
"@nuxtjs/style-resources": "1.0.0",
"@nuxtjs/svg": "0.1.6",
"cookie-universal-nuxt": "2.1.1",
"cross-env": "7.0.0",
"cssnano": "4.1.10",
"cssnano-preset-advanced": "4.0.7",
"imagemin-mozjpeg": "^8.0.0",
"imagemin-webpack-plugin": "^2.4.2",
"intersection-observer": "^0.7.0",
"node-sass": "^4.13.1",
"normalize.css": "8.0.1",
"nuxt": "2.11.0",
"nuxt-trailingslash-module": "1.1.0",
"nuxt-webfontloader": "^1.1.0",
"sass-loader": "^8.0.2",
"vue-js-modal": "1.3.31",
"vue-lazy-hydration": "^1.0.0-beta.12",
"vue-lazyload": "1.3.3",
"vue-svg-loader": "0.11.0",
"vuelidate": "^0.7.5"
},
"devDependencies": {
"@nuxtjs/eslint-config": "2.0.0",
"babel-eslint": "8",
"eslint": "^6.8.0",
"eslint-config-prettier": "6.10.0",
"eslint-config-standard": "14.1.0",
"eslint-friendly-formatter": "4.0.1",
"eslint-loader": "3.0.3",
"eslint-plugin-array-func": "3.1.3",
"eslint-plugin-import": "2.20.1",
"eslint-plugin-jest": "23.7.0",
"eslint-plugin-lodash": "6.0.0",
"eslint-plugin-no-loops": "0.3.0",
"eslint-plugin-no-use-extend-native": "0.4.1",
"eslint-plugin-node": "11.0.0",
"eslint-plugin-prettier": "3.1.2",
"eslint-plugin-promise": "4.2.1",
"eslint-plugin-security": "1.4.0",
"eslint-plugin-standard": "4.0.1",
"eslint-plugin-vue": "6.1.2",
"prettier": "1.19.1",
"prettier-eslint": "9.0.1"
}
}
devDependencies не обязательны, но полезны для автоматического форматирования кода и линтинга.
Теперь по порядку о каждом пакете:
--assets
--scss
--img
--svg
--components
--pages
--category
--checkout
--product
--layouts
--plugins
--store
--middleware (функции которые будут вызываться при переходах между страницами)
--serverMiddleware (то же самое но только server only)
Мы должны задать дефолтную страницу и layout, чтобы для начала хотя бы отрендерить корень сайта.
В layouts создаём файл, который будет шаблоном по умолчанию, если явно не задан другой.
<template>
<nuxt/>
</template>
<script>
export default {
computed: {
meta () {
return [
{ charset: 'utf-8' },
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1 shrink-to-fit=no'
},
{ hid: 'description', name: 'description', content: 'Главная' }
]
}
},
head () {
const canonical = `https://mysite.com${this.$route.path
.toLowerCase()
.replace(//$/, '')}`
return {
meta: [
...this.meta
],
script: [
// { src: 'https://markknol.github.io/console-log-viewer/console-log-viewer.js' }
],
link: [{ rel: 'canonical', href: canonical }]
}
}
}
</script>
Пока что в теге template мы имеем слот в который Nuxt будет рендерить контент каждой страницы с layout по умолчанию. Позже мы вернёмся и добавим туда шапку и футер.
У нас есть одно computed свойство meta(), мы его создали на будущее для того, чтобы можно было задать некоторые мета-теги по умолчанию в зависимости от условий.
В этом свойстве можно обратиться в глобальное хранилище Vuex и например взять информацию о покупателе и его устройстве и на основе этой информации задать метатеги для всех страниц.
Далее свойство head(), которое вызывает Nuxt у всех страниц и шаблонов. В него мы передаём метатеги, а так же задаём канонически ссылки для сайта.
В данном случае не будем мудрить, и вернем просто объект. Который Nuxt отрендерит в это:
<meta data-n-head="ssr" charset="utf-8">
<meta data-n-head="ssr" name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1 shrink-to-fit=no">
<meta data-n-head="ssr" data-hid="description" name="description" content="Главная">
<link data-n-head="ssr" rel="canonical" href="https://mysite.com">
Где:
data-n-head="ssr" Служебный атрибут на который можно не обращать внимание.
data-hid="description" Ранее, в объекте, мы указали свойство hid: 'description'. Оно не является обязательным, но говорит Nuxt присвоить этому конкретному тегу уникальный id, для того, чтобы позже мы могли на других страницах этот тег менять, обращаясь к нему по id. Это очень полезно так как description в layout задан стандартный и для каждого товара и категории товаров он будет своим уникальным (что нужно для SEO).
link: [{ rel: 'canonical', href: canonical }] Очень полезно задавать каноническую ссылку потому, что по умолчанию все страницы в Nuxt регистронезависимые и "слешнезависимые", а это создаёт ненужные нам дубликаты в индексе поиска.
Этим тегом мы динамически задаём тег canonical для всех страниц использующих стандартный шаблон. Технически это не уберёт возможность обращаться к страницам по-разному.
Например, site.com/product/myProduct, site.com/product/myProduct/, site.com/product/MyProduct/
будут разным адресами одной и той же страницы, но у всех их будет одинаковый canonical, что уберёт дубликаты из индекса поисковика.
<link data-n-head="ssr" rel="canonical" href="https://site.com/product/myproduct">
Для удобства можно вынести эту функцию в отдельный файл, но пока оставим это так для наглядности.
Так же вы могли заметить закомментированный тег в script. Эту вещь можно смело раскомментировать во время дебага. Зашли на сайт с телефона и автоматически открылась консоль браузера на телефоне. Очень удобно так как можно не прибегать к более сложным инструментам отладки и экономить себе время.
Мы создали шаблон, создадим и корневую страницу. В папке pages создаём index.vue
<template>
<div>
<h1>Привет!</h1>
</div>
</template>
<script>
export default {
}
</script>
Здесь особо нечего комментировать, выглядит как обычный Vue компонент, который будет отображаться в открытии корня сайта. Позже мы его изменим.
Теперь дадим Nuxt инструкцию как готовить наш сайт. В корне проекта создаём файл nuxt.config.js
const imageminMozjpeg = require('imagemin-mozjpeg')
const ImageminPlugin = require('imagemin-webpack-plugin').default
const isDev = process.env.NODE_ENV !== 'production'
module.exports = {
mode: 'universal',
...(!isDev && {
modern: 'client'
}),
head: {
htmlAttrs: {
lang: 'ru'
},
title: 'Nuxt APP',
meta: [
{ hid: 'description', name: 'description', content: 'Интернет-магазин' }
],
link: [
{ rel: 'shortcut icon', href: 'favicon.ico' }
]
},
rootDir: __dirname,
serverMiddleware: [
],
router: {
prefetchLinks: false
},
loading: { color: '#ddd' },
css: [
'normalize.css',
'./assets/scss/global-styles.scss'
],
plugins: [
],
modules: [
// Doc: https://axios.nuxtjs.org/usage
'@nuxtjs/axios',
'nuxt-trailingslash-module',
'nuxt-webfontloader',
'cookie-universal-nuxt',
'@nuxtjs/style-resources'
],
webfontloader: {
events: false,
google: {
families: ['Montserrat:400,500,600:cyrillic&display=swap']
},
timeout: 5000
},
styleResources: {
// your settings here
// scss: ['./assets/scss/global-variables.scss'], // alternative: scss
less: [],
stylus: []
},
/*
** Axios module configuration
*/
axios: {
// See https://github.com/nuxt-community/axios-module#options
},
render: {
// http2: {
// push: true,
// pushAssets: (req, res, publicPath, preloadFiles) => preloadFiles
// .map(f => `<${publicPath}${f.file}>; rel=preload; as=${f.asType}`)
// },
// compressor: false,
resourceHints: false,
etag: false,
static: {
etag: false
}
},
/*
** Build configuration
*/
build: {
optimizeCss: false,
filenames: {
app: ({ isDev }) => isDev ? '[name].js' : 'js/[contenthash].js',
chunk: ({ isDev }) => isDev ? '[name].js' : 'js/[contenthash].js',
css: ({ isDev }) => isDev ? '[name].css' : 'css/[contenthash].css',
img: ({ isDev }) => isDev ? '[path][name].[ext]' : 'img/[contenthash:7].[ext]',
font: ({ isDev }) => isDev ? '[path][name].[ext]' : 'fonts/[contenthash:7].[ext]',
video: ({ isDev }) => isDev ? '[path][name].[ext]' : 'videos/[contenthash:7].[ext]'
},
...(!isDev && {
html: {
minify: {
collapseBooleanAttributes: true,
decodeEntities: true,
minifyCSS: true,
minifyJS: true,
processConditionalComments: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
trimCustomFragments: true,
useShortDoctype: true
}
}
}),
splitChunks: {
layouts: true,
pages: true,
commons: true
},
optimization: {
minimize: !isDev
},
...(!isDev && {
extractCSS: {
ignoreOrder: true
}
}),
transpile: ['vue-lazy-hydration', 'intersection-observer'],
postcss: {
plugins: {
...(!isDev && {
cssnano: {
preset: ['advanced', {
autoprefixer: false,
cssDeclarationSorter: false,
zindex: false,
discardComments: {
removeAll: true
}
}]
}
})
},
...(!isDev && {
preset: {
browsers: 'cover 99.5%',
autoprefixer: true
}
}),
order: 'cssnanoLast'
},
extend (config, ctx) {
const ORIGINAL_TEST = '/\.(png|jpe?g|gif|svg|webp)$/i'
const vueSvgLoader = [
{
loader: 'vue-svg-loader',
options: {
svgo: false
}
}
]
const imageMinPlugin = new ImageminPlugin({
pngquant: {
quality: '5-30',
speed: 7,
strip: true
},
jpegtran: {
progressive: true
},
gifsicle: {
interlaced: true
},
plugins: [
imageminMozjpeg({
quality: 70,
progressive: true
})
]
})
if (!ctx.isDev) config.plugins.push(imageMinPlugin)
config.module.rules.forEach(rule => {
if (rule.test.toString() === ORIGINAL_TEST) {
rule.test = /.(png|jpe?g|gif|webp)$/i
rule.use = [
{
loader: 'url-loader',
options: {
limit: 1000,
name: ctx.isDev ? '[path][name].[ext]' : 'img/[contenthash:7].[ext]'
}
}
]
}
})
// Create the custom SVG rule
const svgRule = {
test: /.svg$/,
oneOf: [
{
resourceQuery: /inline/,
use: vueSvgLoader
},
{
resourceQuery: /data/,
loader: 'url-loader'
},
{
resourceQuery: /raw/,
loader: 'raw-loader'
},
{
loader: 'file-loader' // By default, always use file-loader
}
]
}
config.module.rules.push(svgRule) // Actually add the rule
}
}
}
Теперь подробно остановимся на каждом пункте конфига
const imageminMozjpeg = require('imagemin-mozjpeg')
const ImageminPlugin = require('imagemin-webpack-plugin').default
Импортируем 2 пакета, которые позже Webpack будет использовать для обработки и сжатия изображений.
const isDev = process.env.NODE_ENV !== 'production'
Для удобства создаём константу, которую будем использовать в качестве флага на некоторых пунктах конфига.
mode: 'universal'
Задаём явно, что мы хотим получить SSR приложение (а не SPA).
...(!isDev && {
modern: 'client'
}),
Используем ES6 синтаксис для динамического расширения объекта. В данном случае !isDev означает, что modern: 'client' свойство объекта будет добавлено только в production. modern: 'client' говорит Nuxt создать 2 бандла, один из которых использует ES6 Modules синтаксис поддерживаемый последними браузерами, а второй Legacy транспилированный через Babel. В html будет по 2 тега на каждый js скрипт вида:
<script nomodule src="***" defer></script><script type="module" src="***" defer>
Браузер будет загружать только один из пары.
head: {
htmlAttrs: {
lang: 'ru'
},
title: 'Nuxt APP',
meta: [
{ hid: 'description', name: 'description', content: 'Интернет-магазин' }
],
link: [
{ rel: 'shortcut icon', href: 'favicon.ico' }
]
}
Создаём дефолтный Head, как мы это делали в нашем layout, но в данном случае этот head будет стандартным для всего приложения. Указываем русский язык, дефолтный Title и Description и путь к нашей favicon.ico (которой пока что нет в проекте, но создадим её позже).
rootDir: __dirname
Явно прописываем что считать корнем проекта при использовании абсолютных путей импорта.
router: {
prefetchLinks: false
}
Отключаем дефолтный механизм Nuxt, который улучшает восприятие UI в некоторых случаях. Этот механизм подгружает страницу как только ссылка на неё попадает в область видимости окна браузера. То есть ещё до клика на ссылку, страница будет уже загружена и пользователь без задержки откроет ссылку. Но в интернет-магазине будут сотни ссылок и нам не нужно, чтобы каждая из них автоматически подгружалась (если у вас слабый сервер, а у клиентов 2G).
loading: { color: '#ddd' }
Бегущая полоска при загрузке страниц, вверху страницы. Это индикатор загрузки, которому можно задать любой цвет или отключить или вставить свой. Он достаточно умный и срабатывает не только при переходе между страницами но при при ожидании ответов по XHR запросам.
css: [
'normalize.css'
]
Задаем глобальные стили для всего приложения. В данном случае подключаем стили из пакета normalize, для сброса дефолтных стилей.
modules: [
'@nuxtjs/axios',
'nuxt-trailingslash-module',
'nuxt-webfontloader',
'cookie-universal-nuxt',
'@nuxtjs/style-resources'
]
Подключаем Nuxt модули, которые по сути являются обычными плагинами для Vue, но уже со встроенным механизмом install, который их запускает в нужном месте. Нам не нужно их вручную добавлять во Vue instance, а только прописать для некоторым из них конфиги, о которых будет далее идти речь.
webfontloader: {
events: false,
google: {
families: ['Montserrat:400,500,600:cyrillic&display=swap']
},
timeout: 5000
}
Задаем конфиг для модуля nuxt-webfontloader. Указываем какой шрифт хотим загрузить. В данном случае берём открытый шрифт из Google Fonts. Когда все шрифты будут загружены, модуль добавит в html тег, класс wf-active. &display=swap означает, что после получения html, когда браузер начнет исполнять js код, это модуль динамически добавит css файл с нужными нам шрифтами. Так же этот модуль умеет вызывать хуки на всех этапах загрузки, но они нам не нужны сейчас, поэтому пропишем events: false.
styleResources: {
scss: ['./assets/scss/global-variables.scss'], // alternative: scss
less: [],
stylus: []
}
Задаем конфиг для модуля @nuxtjs/style-resources. Этот модуль автоматически импортирует указанные файлы стилей для всех компонентов. Обычно используется для подключения глобальных переменных SCSS на весь проект. В данном случае у нас ещё нет файла global-variables.scss, но мы его добавим позже, а модуль не вызовет ошибку, если не найдет файл.
axios: {
// See https://github.com/nuxt-community/axios-module#options
},
Задаем конфиг для модуля axios. Пока что оставим конфиг пустым.
render: {
// http2: {
// push: true,
// pushAssets: (req, res, publicPath, preloadFiles) => preloadFiles
// .map(f => `<${publicPath}${f.file}>; rel=preload; as=${f.asType}`)
// },
// compressor: false,
resourceHints: false
// etag: false,
// static: {
// etag: false
// }
},
Nuxt из коробки имеет под капотом Web Server, который умеет в http2. Но так как проект сейчас на localhost, а для http2 нужен https, то дабы не городить костыли мы просто не будет включать пока что эту опцию.
compressor это Gzip сжатие, которое по умолчанию отдаёт всё сжатым (html, js, css, статику). Подробнее об этом можно почитать здесь https://www.npmjs.com/package/compression [1] Я лишь добавлю, что у себя в production я эту опцию отключил compressor: false так как там Nuxt используется только для render, а всю статику отдаёт Nginx с настроенным сжатием, поэтому дабы дважды не делать одну и ту же работу, можно это отключить. Но для примера мы будем использовать Nuxt для отдачи статики и не будем трогать эту опцию.
То же самое и с etag, если будет использоваться Nginx, то стоит явно отключить etags.
resourceHints мы явно отключим, чтобы не предзагружать страницы (из той же оперы что и prefetchLinks: false) .
optimizeCss: false
Так как позже мы будет использовать cssNano со своими настройками, отключаем дефолтный механизм оптимизации стилей.
filenames: {
app: ({ isDev }) => isDev ? '[name].js' : 'js/[contenthash].js',
chunk: ({ isDev }) => isDev ? '[name].js' : 'js/[contenthash].js',
css: ({ isDev }) => isDev ? '[name].css' : 'css/[contenthash].css',
img: ({ isDev }) => isDev ? '[path][name].[ext]' : 'img/[contenthash:7].[ext]',
font: ({ isDev }) => isDev ? '[path][name].[ext]' : 'fonts/[contenthash:7].[ext]',
video: ({ isDev }) => isDev ? '[path][name].[ext]' : 'videos/[contenthash:7].[ext]'
},
Nuxt сам подставит в Webpack эту часть конфига. Здесь мы задаем то, что во время development хотим видеть красивые имена файлов, а в production build раскидываем файлы по папкам и в качестве имени используем contenthash. Это элегантное решение распространённой проблемы, когда вы скажем обновили приложение на production, а у клиентов все js скрипты и стили в кеше бразуера. Чтобы инвалидировать этот кеш, мы просто каждый раз генерируем у всех файлов название, которое является хеш-функцией контента внутри этого файла. Соответственно если какой-то файл поменяется, то при следующем заходе пользователя на страницу, браузер будет требовать уже файл с другим именем, а те что не поменялись останутся с тем же именем и будут взяты и с кеша браузера.
Как мы видим все файлы и даже html закеширован браузером, что сводит практически к 0 весь трафик между сервером и браузером.
...(!isDev && {
html: {
minify: {
collapseBooleanAttributes: true,
decodeEntities: true,
minifyCSS: true,
minifyJS: true,
processConditionalComments: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
trimCustomFragments: true,
useShortDoctype: true
}
}
})
Отключаем для Development всю минификацию html, чтобы ускорить процесс разработки.
splitChunks: {
layouts: true,
pages: true,
commons: true
},
Разбиваем на независимые чанки всё приложение.
optimization: {
minimize: !isDev
},
Отключаем минификацию js для development.
...(!isDev && {
extractCSS: {
ignoreOrder: true
}
}),
По умолчанию Nuxt автоматически добавляет все стили проекта прямо внутрь html через тег style. Это уменьшает количество запросов к серверу, так как у нас нигде не будет нужно браузеру загружать css файлы отдельно. Но это так же лишает браузер возможности кешировать css стили. Поэтому для development мы включаем inline styles, а в production разбиваем их на чанки для каждого компонента и создаем отдельные файлы.
ignoreOrder: true Нужно задать, чтобы во время сборки нам не выдавал webpack ложные предупреждения о найденных конфликтах и дубликатах, это своеобразное решение бага #4885 [2].
transpile: ['vue-lazy-hydration', 'intersection-observer']
По умолчанию Babel старается трансплитирировать весь код проекта, но иногда он не делает это с некоторыми зависимостями и их нужно задать явно.
postcss: {
plugins: {
...(!isDev && {
cssnano: {
preset: ['advanced', {
autoprefixer: false,
cssDeclarationSorter: false,
zindex: false,
discardComments: {
removeAll: true
}
}]
}
})
},
...(!isDev && {
preset: {
browsers: 'cover 99.5%',
autoprefixer: true
}
}),
order: 'cssnanoLast'
}
Nuxt из коробки имеет встроенный Postcss для которого здесь мы задаём конфиг. Здесь мы можем подключать полезные плагины для Postcss, задавать их порядок исполнения. В данном случае мы отключаем для development все плагины, а в production весь css будет минифицирован и к нему будут присвоены vendor префиксы для 99.5% браузеров. Так же мы можем прописать плагины, которые будут использоваться сразу в двух средах.
extend (config, ctx) {
Здесь мы можем изменять webpack конфиг, напрямую перехватывать нужные loaders или test.
По умолчанию Webpack '/\.(png|jpe?g|gif|svg|webp)$/i'
ищет все картинки и svg файлы и обрабатывает их через встроенный url-loader file-loader для того чтобы мы могли в компонентах импортировать эти файлы. Но он никаких их не сжимает и его возможности по работе с svg сильно ограничены. Поэтому мы изменим это поведение.
Создадим константы в которых будем хранить новые загрузчики и плагины Webpack, а так же строку для поиска встроенного загрузчика, чтобы позже её перехватить.
const ORIGINAL_TEST = '/\.(png|jpe?g|gif|svg|webp)$/i'
const vueSvgLoader = [
{
loader: 'vue-svg-loader',
options: {
svgo: false
}
}
]
const imageMinPlugin = new ImageminPlugin({
pngquant: {
quality: '5-30',
speed: 7,
strip: true
},
jpegtran: {
progressive: true
},
gifsicle: {
interlaced: true
},
plugins: [
imageminMozjpeg({
quality: 70,
progressive: true
})]
})
if (!ctx.isDev) config.plugins.push(imageMinPlugin)
Добавляем плагин для сжатия только в production
config.module.rules.forEach(rule => {
if (rule.test.toString() === ORIGINAL_TEST) {
rule.test = /.(png|jpe?g|gif|webp)$/i
rule.use = [
{
loader: 'url-loader',
options: {
limit: 1000,
name: ctx.isDev ? '[path][name].[ext]' : 'img/[contenthash:7].[ext]'
}
}
]
}
})
Перехватываем стандартный загрузчик и убираем из него svg
const svgRule = {
test: /.svg$/,
oneOf: [
{
resourceQuery: /inline/,
use: vueSvgLoader
},
{
resourceQuery: /data/,
loader: 'url-loader'
},
{
resourceQuery: /raw/,
loader: 'raw-loader'
},
{
loader: 'file-loader' // By default, always use file-loader
}
]
}
config.module.rules.push(svgRule)
Добавляем наш загрузчик для svg
На этом настройка nuxt.config.js завершена и мы можем запустить проект.
Для начала запустим Nuxt в режиме development. Для этого пропишем в консоли yarn dev
Заходим в браузере по адресу http://localhost:3000/ [3] и видим, что всё хорошо.
Теперь запустим production сервер, отключаем dev server с помощью Ctrl+C в консоли пишем yarn buildandstart
Сервер будет доступен по тому же порту, но его можно изменить в package.json. Давайте проверим скорость.
Попробуем разобраться почему выдаёт 99/100
Из всего этого следует, что если немного подшаманить то без особых сложностей мы получим вот это, но это уже отдельная история с подготовкой сервера к HighLoad и тд.
Так как мы не будем использовать API, а будем эмулировать работу с ним, то для примера инкапсулируем всю логику запросов на сервер через Vuex.
Для начала создадим в корне папку store и файл index.js с таким содержанием
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))
const categories = [
{
cName: 'Котики',
cSlug: 'cats',
cImage: 'https://source.unsplash.com/300x300/?cat,cats'
},
{
cName: 'Собачки',
cSlug: 'dogs',
cImage: 'https://source.unsplash.com/300x300/?dog,dogs'
},
{
cName: 'Волчки',
cSlug: 'wolfs',
cImage: 'https://source.unsplash.com/300x300/?wolf'
},
{
cName: 'Бычки',
cSlug: 'bulls',
cImage: 'https://source.unsplash.com/300x300/?ox'
}
]
export const state = () => ({
categoriesList: []
})
export const mutations = {
SET_CATEGORIES_LIST (state, categories) {
state.categoriesList = categories
}
}
export const actions = {
async getCategoriesList ({ commit }) {
try {
await sleep(1000)
await commit('SET_CATEGORIES_LIST', categories)
} catch (err) {
console.log(err)
throw new Error('Внутреняя ошибка сервера, сообщите администратору')
}
}
}
Если вы не знакомы с Vuex под спойлером будет подробное описание файла
const sleep = m => new Promise(r => setTimeout(r, m))
Функция, которая позволит нам симулировать задержку с сервера внутри Async функций.
export const state = () => ({
categoriesList: []
})
Это наше глобальное состояние приложения. В нём мы прописываем дефолтные значения переменных.
export const mutations = {
SET_CATEGORIES_LIST (state, categories) {
state.categoriesList = categories
}
}
Через функции, которые называются мутациями мы можем изменить состояние. Не рекомендуется менять его напрямую. Я намеренно опустил этап валидации и тд. поэтому всё сводится просто к присвоению переменной.
export const actions = {
async getCategoriesList ({ commit }) {
try {
await sleep(1000)
await commit('SET_CATEGORIES_LIST', categories)
} catch (err) {
console.log(err)
throw new Error('Внутреняя ошибка сервера, сообщите администратору')
}
}
}
Здесь будут наши actions, это тоже функции, но они не меняют напрямую состояние, но зато получают в качестве первого аргумента объект store из которого в данном случае нам нужна функция comit, которая получает в качестве первого аргумента имя мутации, а второго данные, которые она передаёт в мутацию. А в качестве второго аргумента action получает контекст Nuxt где мы можем обращаться к разным плагинам, например к тому же axios и работать через него с реальным API.
По сути в actions будет проходить вся работа с API. Мы можем также вызывать другие actions, таким образом создавая цепочки. В данном случае мы симулируем работу с API, но в будущем можем заменить её на реальный API при этом на не придется переписывать код компонентов и страниц так как вся логика работы скрыта.
В папке components создаём папку commons, в которой мы будем хранить общие компоненты. Далее создаём файл CategoriesList.vue с таким содержанием:
<template>
<div>
<h2>Список категорий</h2>
<div :class="$style.wrapper">
<div
v-for="category in categories"
:key="category.cSlug"
:class="$style.block"
>
<nuxt-link :to="`/category/${cSlug}`">
<p>{{ category.cName }}</p>
<img :src="category.cImage" />
</nuxt-link>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
categories: {
type: Array,
default: () => []
}
}
}
</script>
<style lang="scss" module>
.wrapper {
display: flex;
}
.block {
display: flex;
flex-direction: column;
}
</style>
<style lang="scss" module>
.wrapper {
display: flex;
}
.block {
display: flex;
flex-direction: column;
}
</style>
Для примера будем использовать относительно новую функцию CSS Modules, которая идёт из коробки и превращает например класс wrapper в wrapper_26mY3 , что позволяет вам не задумывать об именовании локальных классов в стилях компонентов.
К классам придётся обращаться через объект $style в данном случае.
props: {
categories: {
type: Array,
default: () => []
}
}
Компонент будет получать данные из родительского, это позволит сделать его dump (глупым) и уменьшает сложность приложения. То есть он не работает с API или глобальным состоянием.
<nuxt-link :to="`/category/${cSlug}`">
Nuxt-link это встроенная обвёртка вокруг <router-link> , которая берёт на себя переходы между страницами, а так же имеет дополнительные функции, которые мы отключили ранее (предзагрузка страницы, когда эта ссылка появится в области видимости.
Мы передаём в атрибут to строковый литерал с переменной cSlug в конце, в которой у нас будут храниться уникальные пути к каждой категории. В итоге в случае первой категории это отрендерится в
<a href="/category/cats" class=""><p>Котики</p> <img src="https://source.unsplash.com/300x300/?cat,cats"></a>
И хотя в dom это обычный a href, но на нём висят обработчики кликов и IObserver. Поэтому даже если у пользователя отключен JS в браузере, он сможет переходить по ссылкам и получать отрендеренный контент с сервера.
Теперь мы хотим отобразить этот компонент на главной странице сайта.
<template>
<div>
<h1>Интернет-магазин "Хвостики"</h1>
<CategoriesList :categories="categories" />
</div>
</template>
<script>
import CategoriesList from '~~/components/common/CategoriesList'
import { mapState } from 'vuex'
export default {
components: {
CategoriesList
},
async asyncData ({ app, route, params, error, store }) {
try {
await store.dispatch('getCategoriesList')
} catch (err) {
console.log(err)
return error({
statusCode: 404,
message: 'Категории не найдены или сервер не доступен'
})
}
},
computed: {
...mapState({
categories: 'categoriesList'
})
}
}
</script>
import CategoriesList from '~~/components/common/CategoriesList'
Импортируем наш список категорий используя ~~, что означает абсолютный путь.
import { mapState } from 'vuex'
Нам нужно получить глобальное состояние в котором храниться список категорий.
async asyncData ({ app, route, params, error, store }) {
try {
await store.dispatch('getCategoriesList')
} catch (err) {
console.log(err)
return error({
statusCode: 404,
message: 'Категории не найдены или сервер не доступен'
})
}
},
asyncData Это специальный метод, который вызывает Nuxt на сервере. В нём мы можем получить данные из API напрямую, но в данном случае, мы хотим их получить вызвав определённый action в Vuex. Так же перед функцией есть приставка async , что позволяет нам сказать Nuxt не рендерить страницу пока данные из API не получены.
computed: {
...mapState({
categories: 'categoriesList'
})
}
Через обвертку получаем в страницу объект categories привязанный к глобальному состоянию categoriesList
<CategoriesList :categories="categories" />
Выводим этот компонент, передавая ему в props объект categories
При нажатии на категорию nuxt пытается найти маршрут, но он его не найдет. Давайте создадим для каждой категории свой маршрут.
В папке pages создаём папку categories и в ней файл с именем _CategorySlug.vue такого содержания:
<template>
<div>
<h1>{{ category.cName }}</h1>
<p>{{ category.cDesc }}</p>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
async asyncData ({ app, params, route, error }) {
try {
await app.store.dispatch('getCurrentCategory', { route })
} catch (err) {
console.log(err)
return error({
statusCode: 404,
message: 'Категория не найдена или сервер не доступен'
})
}
},
computed: {
...mapState({
category: 'currentCategory'
})
},
head () {
return {
title: this.category.cTitle,
meta: [
{
hid: 'description',
name: 'description',
content: this.category.cMetaDescription
}
]
}
}
}
</script>
Название файла должно начинаться с _, чтобы Nuxt понял, что это динамический маршрут.
Когда мы переходим на страницу http://127.0.0.1:3000/category/cats [4]
Мы можем обратиться к объекту route таким образом route.params.CategorySlug (без нижнего подчеркивания), которое будет равным cats
В этом файле все аналогично index.vue, но есть пару отличий
await app.store.dispatch('getCurrentCategory', { route })
Мы вызываем actions, который мы создадим в следующем шаге, но вторым аргументам передаём вышеупомянутый объект route.
head () {
return {
title: this.category.cTitle,
meta: [
{
hid: 'description',
name: 'description',
content: this.category.cMetaDescription
}
]
}
}
Прописываем для этой страницы Title и Meta description, которые мы получаем из API.
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))
const categories = [
{
cTitle: 'Котики',
cName: 'Котики',
cSlug: 'cats',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?cat,cats'
},
{
cTitle: 'Собачки',
cName: 'Собачки',
cSlug: 'dogs',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?dog,dogs'
},
{
cTitle: 'Волчки',
cName: 'Волчки',
cSlug: 'wolfs',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?wolf'
},
{
cTitle: 'Бычки',
cName: 'Бычки',
cSlug: 'bulls',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?bull'
}
]
export const state = () => ({
categoriesList: [],
currentCategory: {}
})
export const mutations = {
SET_CATEGORIES_LIST (state, categories) {
state.categoriesList = categories
},
SET_CURRENT_CATEGORY (state, category) {
state.currentCategory = category
}
}
export const actions = {
async getCategoriesList ({ commit }) {
try {
await sleep(1000)
await commit('SET_CATEGORIES_LIST', categories)
} catch (err) {
console.log(err)
throw new Error('Внутреняя ошибка сервера, сообщите администратору')
}
},
async getCurrentCategory ({ commit }, { route }) {
await sleep(1000)
const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)
await commit('SET_CURRENT_CATEGORY', category)
}
}
getCurrentCategory в этом action на основании текущего route мы ищем нужную категорию и добавляем её в state.
Теперь наши маршруты работают.
Я использовал первый попавшийся генератор JSON с такими настройками.
Полученный файл кладем в static/mock. Nuxt предоставляет публичный доступ к файлам, которые лежат в папке static. Поэтому мы сможем подтянуть наши товары используя Axios.
Для начала изменим наш Vuex к такому виду:
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))
const categories = [
{
id: 'cats',
cTitle: 'Котики',
cName: 'Котики',
cSlug: 'cats',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?cat,cats',
products: []
},
{
id: 'dogs',
cTitle: 'Собачки',
cName: 'Собачки',
cSlug: 'dogs',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?dog,dogs',
products: []
},
{
id: 'wolfs',
cTitle: 'Волчки',
cName: 'Волчки',
cSlug: 'wolfs',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?wolf',
products: []
},
{
id: 'bulls',
cTitle: 'Бычки',
cName: 'Бычки',
cSlug: 'bulls',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?bull',
products: []
}
]
function addProductsToCategory (products, category) {
const categoryInner = { ...category, products: [] }
products.map(p => {
if (p.category_id === category.id) {
categoryInner.products.push({
id: p.id,
pName: p.pName,
pSlug: p.pSlug,
pPrice: p.pPrice,
image: `https://source.unsplash.com/300x300/?${p.pName}`
})
}
})
return categoryInner
}
export const state = () => ({
categoriesList: [],
currentCategory: {},
currentProduct: {}
})
export const mutations = {
SET_CATEGORIES_LIST (state, categories) {
state.categoriesList = categories
},
SET_CURRENT_CATEGORY (state, category) {
state.currentCategory = category
},
SET_CURRENT_PRODUCT (state, product) {
state.currentProduct = product
}
}
export const actions = {
async getCategoriesList ({ commit }) {
try {
await sleep(1000)
await commit('SET_CATEGORIES_LIST', categories)
} catch (err) {
console.log(err)
throw new Error('Внутреняя ошибка сервера, сообщите администратору')
}
},
async getCurrentCategory ({ commit }, { route }) {
await sleep(1000)
const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)
const products = await this.$axios.$get('/mock/products.json')
await commit('SET_CURRENT_CATEGORY', addProductsToCategory(products, category))
}
}
Здесь мы переделали action getCurrentCategory , который с помощью axios получает файл products.json. После небольшой обработки мы получаем страницу категории со списком всех товаров и ссылками на них.
Чтобы их отрендерить создадим компонент ProductBrief.vueв components/category.
<template>
<div :class="$style.wrapper">
<nuxt-link :to="`/product/${product.pSlug}`">
<p>{{ product.pName }}</p>
<img :src="product.image" />
</nuxt-link>
<p>Цена {{ product.pPrice }}</p>
</div>
</template>
<script>
export default {
props: {
product: {
type: Object,
default: () => {}
}
}
}
</script>
<style lang="scss" module>
.wrapper {
display: flex;
flex-direction: column;
}
</style>
Это dump компонент, который будет являться карточкой товара.
Изменим нашу страницу категории
<template>
<div>
<h1>{{ category.cName }}</h1>
<p>{{ category.cDesc }}</p>
<div :class="$style.productList">
<div
v-for="product in category.products"
:key="product.id"
>
<ProductBrief :product="product" />
</div>
</div>
</div>
</template>
<script>
import ProductBrief from '~~/components/category/ProductBrief'
import { mapState } from 'vuex'
export default {
components: {
ProductBrief
},
async asyncData ({ app, params, route, error }) {
try {
await app.store.dispatch('getCurrentCategory', { route })
} catch (err) {
console.log(err)
return error({
statusCode: 404,
message: 'Категория не найдена или сервер не доступен'
})
}
},
computed: {
...mapState({
category: 'currentCategory'
})
},
head () {
return {
title: this.category.cTitle,
meta: [
{
hid: 'description',
name: 'description',
content: this.category.cMetaDescription
}
]
}
}
}
</script>
<style lang="scss" module>
.productList {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
</style>
Теперь она будет рендерить все товары в категории в таком виде.
Всё хорошо, но в категории 150 товаров и это напрягает мой мобильный интернет, который пытается загрузить все картинки. Давайте сделаем загрузку ленивой.
Для этого в plugins добавим
import Vue from 'vue'
import VueLazyload from 'vue-lazyload'
export default async (context, inject) => {
Vue.use(VueLazyload, {
preLoad: 0,
error: 'https://via.placeholder.com/300',
// eslint-disable-next-line
loading: require(`${'~~/assets/svg/download.svg'}`),
attempt: 3,
lazyComponent: true,
observer: true,
throttleWait: 500
})
}
В котором мы указываем индикатор загрузки и placeholder в случае, если картинка не доступна на сервере.
Подключаем плагин в nuxt.config.js
plugins: [
{ src: '~~/plugins/vue-lazy-load.js' }
],
И меняем наш img srcна
<img
v-lazy="product.image"
:class="$style.image"
/>
Так же добавляем стиль
.image {
width: 300px;
height: 300px;
}
И вуаля
Анимированый индикатор загрузки и ленивая загрузка. Мой смартфон выдохнул.
На этом я хочу закончить первую часть. Код проекта доступен на Github [5]
Потыкать можно тут.
Спасибо всем кто прочитал статью. Это просто практический пример использования Nuxt. В реальном проекте нужно исходить из бизнес-логики, структуры данных и тд. тп, а этот проект далек от реальности.
В продолжении мы будем наращивать функционал сайта, так как пока что он никуда не годится.
Автор: Антон Москальченко
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/348220
Ссылки в тексте:
[1] https://www.npmjs.com/package/compression: https://www.npmjs.com/package/compression
[2] #4885: https://github.com/nuxt/nuxt.js/issues/4885
[3] http://localhost:3000/: http://localhost:3000/
[4] http://127.0.0.1:3000/category/cats: http://127.0.0.1:3000/category/cats
[5] Github: https://github.com/AntonMoskalchenko/habr-ecommerce-for-article
[6] Image: https://codesandbox.io/s/compassionate-mendel-neeez?fontsize=14&hidenavigation=1&theme=dark
[7] Источник: https://habr.com/ru/post/490496/?utm_source=habrahabr&utm_medium=rss&utm_campaign=490496
Нажмите здесь для печати.