Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1

в 14:48, , рубрики: html, javascript, node.js, nuxt, nuxtjs, server side rendering, ssr, vue.js, vuejs, webpack

Статья ориентированна на людей, которые уже имеют понимание работы Vue, на котором основан Nuxt, поэтому я буду заострять внимание только на специфических для Nuxt вещах. Но даже если вы не знакомы с ними, то статья даст общее представление как выглядит проект с PWA Nuxt.

Вы сможете почерпнуть полезные хаки, плагины и способы решения проблем, которые часто возникают при создании Nuxt приложений.

В этой статье я хочу поделиться как создать примитивный интернет-магазин:

  • Который будет быстро загружаться у пользователя.
  • Который полюбит Google (или любой другой поисковик) с точки зрения SEO.

Для упрощения восприятия процесса в этой статье не будет разбираться создание backend api, так как эта тема довольно объёмная и тянет на отдельную статью.

Введение

Преимущество прогрессивных (PWA) фреймворков вроде Nuxt.js в том, что:

  • Вам не нужно заботиться об отдаче html с помощью пререндера как в случае работы с SPA для поисковых роботов.
  • Более быстрое взаимодействие с сайтом, в сравнении со статическими сайтами, за счет подгрузки только необходимых js chunks, css styles и api запросов (большую часть этого процесса автоматизирует webpack 4, который работает под капотом nuxt.js)
  • Высокие показатели Google Lighthouse при правильно настройке, о которой будет идти речь в статье (можно набрать 100/100 даже на слабом сервере).
  • CSS Modules, Babel из коробки

Структура интернет магазина:

  • Главная страница с каталогом товаров
  • Страница категорий со списком товаров
  • Карточка товара
  • Страница оформления заказа

Возможности приложения:

  • Модальные окна (нужные при нажатии на кнопку купить).
  • Хранение идентификатора пользователя в cookie (без авторизации для упрощения).
  • Ленивая подгрузка и гидрация компонентов.
  • Сжатие изображений, стилей, скриптов, html кода и всего что только можно.
  • 2 версии сайта: 1) для последних браузеров которым не нужен транспилированный код (через Babel) 2) Legacy версия транспилированная через Babel.
  • Умная загрузка шрифтов без FOUT, FOIT, FOFT (распространённая проблема при использовании своих шрифтов, пока они не загрузятся у пользователя, он не будет видеть текст).
  • Поддержка всех браузеров от IE 10.
  • Поддержка svg.
  • Работа со всеми SEO тегами.
  • Автоопределение устройства пользователя ещё до рендера сайта, для отдачи нужного html контента.
  • Работа с API с помощью XHR запросов.

Установка Nuxt

И так начнем. Я буду использовать Node v12.16.1 и Yarn v1.22.0.
Создаем папку, открываем её вставляем package.json такого содержания и прописываем в консоли yarn install

package.json

{
  "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 не обязательны, но полезны для автоматического форматирования кода и линтинга.

Теперь по порядку о каждом пакете:

  • @nuxtjs/axios Удобная работа с XHR запросами.
  • @nuxtjs/style-resources Позволяет создать файл с глобальными переменными для sass, который будет включен на этапе сборке в каждый компонент.
  • cookie-universal-nuxt Универсальное api для работы с cookie как на сервере так и клиенте
  • cross-env Актуально если работаете из под Windows, позволяет прописывать переменные окружения (можно вместо него использовать dotenv)
  • cssnano Хороший модуль для сжатия css, устранение дубликатов и тд.
  • image-webpack-loader Пережимаем JPG, GIF, WEBP, PNG с потерей или без потери качества.
  • intersection-observer Полифил для Intersection Observer API (нужен для ленивой загрузки картинок и компонентов).
  • node-sass Webpack загрузчик для scss.
  • normalize.css Сбрасываем дефолтные стили браузеров.
  • nuxt-trailingslash-module Удаляем лишние слэши в конце адресной строки (для SEO).
  • nuxt-webfontloader Умная загрузка шрифтов.
  • vue-js-modal Модальные окна.
  • vue-lazy-hydration Уменьшаем Max Potential First Input Delay (очень узкое место при работе с любыми SSR фреймворками, так как после получения html и скриптов, они начинаются гидрацию, которая увеличивает этот показатель при наличии множества компонентов на странице).
  • vue-lazyload Ленивая подгрузка картинок.
  • vue-svg-loader SVG загрузчик.
  • vuelidate Удобная валидация форм.

Структура проекта

--assets 
  --scss
  --img
  --svg
--components
--pages
  --category
  --checkout
  --product
--layouts
--plugins 
--store 
--middleware (функции которые будут вызываться при переходах между страницами)
--serverMiddleware (то же самое но только server only)

  • assets Здесь хранятся все ассеты, которые мы позже будем импортировать вручную.
  • components Вручную задаем удобную для нас структуру папок со всеми компонентами Vue
  • pages Папка, в которой, мы складываем наши страницы, которые позже будет переданы как маршруты в Vue Router. Позже разберём это подробнее.
  • layouts Папка, в которой, мы складываем обвёртки вокруг страниц, в которых, мы задаём общие для страниц компоненты (вроде шапки и футера).
  • plugins В этой папке мы будем задавать внешние плагины в зависимости от разных условий. Об этом позже.

Создаём layout

Мы должны задать дефолтную страницу и layout, чтобы для начала хотя бы отрендерить корень сайта.

В layouts создаём файл, который будет шаблоном по умолчанию, если явно не задан другой.

default.vue

<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. Эту вещь можно смело раскомментировать во время дебага. Зашли на сайт с телефона и автоматически открылась консоль браузера на телефоне. Очень удобно так как можно не прибегать к более сложным инструментам отладки и экономить себе время.

Про остальные теги

  • name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1 shrink-to-fit=no" запрещаем зум на мобильных устройствах (кроме Safary iOS).
  • charset="utf-8" задаём кодировку страницы

Создаём страницу

Мы создали шаблон, создадим и корневую страницу. В папке pages создаём index.vue

index.vue

<template>
    <div>
<h1>Привет!</h1>
    </div>
</template>

<script>
    export default {

    }
</script>

Здесь особо нечего комментировать, выглядит как обычный Vue компонент, который будет отображаться в открытии корня сайта. Позже мы его изменим.

Конфигурация Nuxt

Теперь дадим Nuxt инструкцию как готовить наш сайт. В корне проекта создаём файл nuxt.config.js

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 Я лишь добавлю, что у себя в 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 скрипты и стили в кеше бразуера. Чтобы инвалидировать этот кеш, мы просто каждый раз генерируем у всех файлов название, которое является хеш-функцией контента внутри этого файла. Соответственно если какой-то файл поменяется, то при следующем заходе пользователя на страницу, браузер будет требовать уже файл с другим именем, а те что не поменялись останутся с тем же именем и будут взяты и с кеша браузера.

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 1

Как мы видим все файлы и даже 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.

  • 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/ и видим, что всё хорошо.

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 2

Теперь запустим production сервер, отключаем dev server с помощью Ctrl+C в консоли пишем yarn buildandstart

Сервер будет доступен по тому же порту, но его можно изменить в package.json. Давайте проверим скорость.

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 3

Попробуем разобраться почему выдаёт 99/100

  1. Мы используем динамическую инициализацию шрифтов, которая начинается, когда уже сам app.js начинает исполнятся в браузере. После инициализации они подгружаются с Google Fonts. Всё это происходит практически в конце загрузки страницы. Если отключить Webfonts Loader, мы получим 100/100, но мы этого делать не будем.
  2. Мы не используем http2 поэтому браузер начинает загрузку всех ресурсов уже после получения и парсинга html, поэтому в production это процесс примерно на 0,5-1с. начинается раньше (при правильных настройках Nuxt и Nginx).
  3. По умолчанию Lighthouse включает 4x CPU Throttling, чтобы симулировать более медленные процессоры, что сильно увеличивает длительность инициализации скриптов.
  4. Lighthouse игнорирует все возможные кеши и симулирует первый заход на сайт. Наше приложение будет разбито на десятки чанков для кеша, поэтому в реальности для конечного пользователя вторая загрузка страницы будет намного быстрее.
  5. Стандартный вебсервер плохо отдаёт статику, поэтому его рекомендуется заменить на Nginx

Из всего этого следует, что если немного подшаманить то без особых сложностей мы получим вот это, но это уже отдельная история с подготовкой сервера к HighLoad и тд.

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 4

Наполняем сайт

Создаём Vuex store

Так как мы не будем использовать API, а будем эмулировать работу с ним, то для примера инкапсулируем всю логику запросов на сервер через Vuex.

Для начала создадим в корне папку store и файл index.js с таким содержанием

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 под спойлером будет подробное описание файла

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 с таким содержанием:

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 в браузере, он сможет переходить по ссылкам и получать отрендеренный контент с сервера.

Перепишем корневой index.vue

Теперь мы хотим отобразить этот компонент на главной странице сайта.

index.vue

<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

Запускаем сервер

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 5

При нажатии на категорию nuxt пытается найти маршрут, но он его не найдет. Давайте создадим для каждой категории свой маршрут.

Создаём страницу категории

В папке pages создаём папку categories и в ней файл с именем _CategorySlug.vue такого содержания:

_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
Мы можем обратиться к объекту 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.

Изменяем Vuex

index.js

// 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 с такими настройками.
Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 6
Полученный файл кладем в static/mock. Nuxt предоставляет публичный доступ к файлам, которые лежат в папке static. Поэтому мы сможем подтянуть наши товары используя Axios.

Для начала изменим наш Vuex к такому виду:

index.js

// 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.

ProductBrief.vue

<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 компонент, который будет являться карточкой товара.

Изменим нашу страницу категории

_CategorySlug.vue

<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>

Теперь она будет рендерить все товары в категории в таком виде.

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 7

Всё хорошо, но в категории 150 товаров и это напрягает мой мобильный интернет, который пытается загрузить все картинки. Давайте сделаем загрузку ленивой.

Для этого в plugins добавим

vue-lazy-load.js

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;
}

И вуаля

Создаем прогрессивный PWA интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 1 - 8

Анимированый индикатор загрузки и ленивая загрузка. Мой смартфон выдохнул.

Конец первой части

На этом я хочу закончить первую часть. Код проекта доступен на Github

Потыкать можно тут.

Edit compassionate-mendel-neeez

Спасибо всем кто прочитал статью. Это просто практический пример использования Nuxt. В реальном проекте нужно исходить из бизнес-логики, структуры данных и тд. тп, а этот проект далек от реальности.

В продолжении мы будем наращивать функционал сайта, так как пока что он никуда не годится.

Автор: Антон Москальченко

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js