Vue + SSR + AMP — как подружить SPA с гугл страницами

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Привет, хабрист!

Довольно давненько подружил свои приложения с гуглом.

Основная идея была - не создавая новых шаблонов, получить все страницы сайта AMP-friendly и, вообще, сделать ядро приложения AMP-ready.

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

Я буду вещать на примере самого простого - картинок. Все прочее аналогично, хоть и посложнее на практике.

Объявим зависимости

package.json
{
  "name": "ssr.app",
  "version": "0.0.1",
  "description": "google-ready app",
  "productName": "AMP-friendly",
  "dependencies": {
    "@vue/composition-api": "^1.0.0-rc.5",
    "vue": "^2.6.14",
    "vue-meta": "^2.4.0",
    "vuex": "^3.6.2",
    "vuex-composition-helpers": "^1.0.23"
  },
  "devDependencies": {
    "pug": "3.0.2",
    "pug-plain-loader": "1.1.0",
    "purgecss-webpack-plugin": "^4.1.3",
    "terser-webpack-plugin": "4.2.3",
  }
}

Теперь сделаем дефолтную функцию для сбора метаданных из компонентов. Это архи важный момент. На данном этапе будут засасываться стили, подключаться скрипты с сайта ampproject.org и прочая SEO требуха ))

app.meta.js
export const keyTitle = 'header'
export const keyDescription = 'description'
export const keyMeta = 'meta'
export const keyCanonical = 'canonical'
export const keyAmp = 'amphtml'
export const keyImage = 'image'
export const keyItem = 'item'

export const defaultMediaTitle = 'My awesome site'

const ampBoilerplate = { vmid: 'amp_meta', 'amp-boilerplate': true, cssText: 
  'body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}'
                       }
const ampNoScript = { vmid: 'amp_meta', innerHTML: `
<style amp-boilerplate>
  body {-webkit-animation: none;-moz-animation: none;-ms-animation: none;animation: none;}
</style>
`}


// сюда положим стили приложения, они сложены в чанки
const ampCSS = { loaded: false }

// тут берем из компонента информацию для meta
const getMeta = (key, item, route) => {
  if (item && item[key]) return item[key]
  return route.meta[key]
}

// тут нам придется пробежать по всем mounted компонентам
// нам нужно собрать стили для async компонентов
const getSources = (map, cmp) => {
  const { $options, $children } = cmp
  const { __file: file } = $options || {}
  if (file) map[file] = ''
  if ($children) $children.forEach((v) => getSources(map, v))
}

export function metaInfo() {
  const cmp = this
  const { $route: route, $store: store } = cmp
  
  // если компонент возвращает какой-то item
  // то метаданные берем из этого item
  // иначе, компонент должен сам возвращать метаданные
  // может быть, роут содержит в свойстве meta какие-то данные
  // вообще иначе, метаданных не будет и SEO провалится (:
  const item = cmp[cmp.keyItem || keyItem] || cmp

  // приведу в пример только тайтл и дескрипшон страницы
  const __title = getMeta(cmp.keyTitle || keyTitle, item, route)
  const title = __title || defaultMediaTitle
  const description = getMeta(cmp.keyDescription || keyDescription, item, route)
  
  // с этого начинается google AMP
  const ampLink = getMeta(cmp.keyAmp || keyAmp, item, route)

  const out = {
    title,
    meta: [{ name: 'description', content: description }],
    link: [],
    script: [],
    noscript: [],
    style: [],
    htmlAttrs: {
      lang: 'ru',
    },
    // вот с этим нужно быть очень внимательными
    // но без этого у нас выведется на страницу что-то такое
    // &lt;style&gt;
    __dangerouslyDisableSanitizersByTagID: { amp_meta: ['innerHTML', 'cssText'] },
  }
  
  // if (true) { // debug
  if (route.params?.isAmp) { // подробнее в router.js
    if (SSR) { // isServer
      const { ssrContext } = store // подробнее в server.js
      const { initialCSS: cssSources, allCSS, chunkMap } = ssrContext
      
      if (!ampCSS.loaded) {
        // засасываем CSS в кеш
        ampCSS.loaded = true
        const fs = require('fs')

        const dir = './www/' // где лежат наши CSS-ки?
        allCSS.forEach((f) => {
          const contents = fs.readFileSync(dir + f, { encoding: 'utf-8' })
          ampCSS[f] = contents || ''
        })
      }

      // нужно собрать все async mounted компоненты
      // получим 
      /*
      { 
      	'src/views/Home.vue': 'my-home-page',
        'src/components/Async.vue': 'my-async-chunk'
      }
      */
      const sources = {}
      getSources(sources, this.$root)
      
      // добавим к initialCSS наши асинхронные чанки
      Object.keys(sources).forEach((k) => (sources[k] = chunkMap[k] || sources[k]))
      
      Object.keys(sources).forEach((k) => {
        const start = 'css/' + sources[k]
        const css = allCSS.find((v) => v.startsWith(start))
        if (css && !cssSources.includes(css)) cssSources.push(css.replace('"', "'"))
      })
      
      // сформируем контент для тега style
      // возьмем его из кеша ampCSS
      const cssContents = cssSources
        .map((f) => ampCSS[f])
        .filter((v) => !!v)
        .join('\n')
      
      // и сформируем AMP-ready теги
      out.htmlAttrs['⚡'] = true
      
      // тут же, нужно подключить скрипты для компонентов
      // amp-img, amp-iframe, amp-analytics и прочее
      out.script.push({ async: true, src: 'https://cdn.ampproject.org/v0.js', crossorigin: 'anonymous' })
      
      // __dangerouslyDisableSanitizersByTagID
      out.noscript.push(ampNoScript)
      out.style.push(ampBoilerplate)
      out.style.push({ vmid: 'amp_meta', 'amp-custom': true, cssText: cssContents })
    }
  }else if (ampLink) {
    // если страница имеет AMP версию, то
    out.link.push({ rel: 'amphtml', href: ampLink })
  }
	return { ...out }
}

По большому счету, это все. Осталось подключить эту функцию через роутер к компонентам и сделать AMP-ready роуты

Создание роутера я упущу, продемонстрирую только сами роуты

routes-patch.js
const injectProps = (route, props) => {
  const newProps = {}
  const keys = []
  if (props?.isAmp) route.params.isAmp = true
  route.matched.forEach(cmp => {
    if ((cmp.components.default.props || {}).isAmp) route.isAmp = true
    Object.keys(cmp.components.default.props || {}).forEach(k => {
      if (!keys.includes(k)) keys.push(k)
    })
  })
  keys.forEach(k => {
    if (notNull(props[k])) newProps[k] = props[k]
  })
  return newProps
}

export const parseParams = (route, add) =>
  injectProps(route, {
    ...route?.params,
    ...route?.query,
    ...(isObject(add) ? add : {}),
  })

function injectMeta(route) {
  // тут подключаем menaInfo ко всем эндпоинтам, кроме узлов
  if (route.children?.length) route.children.forEach(r => injectMeta(r))
  else {
    // эндпоинт может быть как синхронным, так и асинхронным компонентом
    const oldComponent = route.component
    if (isFn(oldComponent)) {
      route.component = () =>
      	// подменим асинхронный вызов
        // мы же обернули асинронный импорт в функцию
      	// ни кто не запретит нам сделать это еще раз
        new Promise((resolve, reject) => {
          oldComponent()
            .then(cmp => {
            	// если мы объявили в компоненте metaInfo
            	// то не будем его затирать
            	// тан надо работать ручками
              if (cmp.default && !cmp.default.metaInfo) cmp.default.metaInfo = metaInfo
              resolve(cmp)
            })
            .catch(reject)
        })
    } else if (oldComponent && !oldComponent.metaInfo) oldComponent.metaInfo = metaInfo
  }
}

Ну, и, наконец, сами роуты

routes.js
import layoutMain from 'src/layouts/MainLayout.vue'
/*
вот зачем это
webpackChunkName: "home-page"
js/home-page[.hash]?.js
css/home-page[.other hash]?.css
*/
const HomePage = () => import(/* webpackChunkName: "home-page" */ 'src/views/Home.vue')
const err404 = () => import(/* webpackChunkName: "404" */ 'src/views/404.vue')
const route1 = {
  path: '',
  component: HomePage,
  // отсюда можно задать дефолтные значения чего-нибудь
  // или передать статичные флаги
  // route{:param1}/{:param2}?param3=value
  // будут доступны через
  // $route.params.param[1,2,3]
  // или 
  // $route.query.param3 - это дефолтное поведение
  // или через
  /*
  src/views/Home.vue
  export default {
  	props:{param1:String,param2:String,param3:String, meta:Object}
  }
  */
  props: (route) => parseParams(route, { meta:{title:'My home page'} /* some static props */ }),
}
const route404 = {
  path: '*',
  component: err404,
  props: (route) => parseParams(route, { meta:{title:'Oooops...'} /* some static props */ }),
}
const children = [route1,route404]
const routes = [
  {
    path: '/amp',
    component: layoutMain,
    // { isAmp: true }
    // достаточно только в корне поставить этот флаг
    props: (route) => parseParams(route, { isAmp: true }),
    children,
  },
  {
    path: '/',
    component: layoutMain,
    children,
  },
]
// подключаем дефолтную функцию ко всем эндпоинтам
routes.forEach(r => injectMeta(r))
export default routes

Конфигурим сервер, парсим бандлы, вытаскиваем из них сорцмапы

Важно! Серверный бандл обязательно должен содержать сорцмап

server.js
const path = require('path')
const fs = require('fs')

const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(path.resolve('./server-bundle.json'))
const clientManifest = require(path.resolve('./client-manifest.json'))

const chunkMap = {}
const { maps } = bundle
Object.keys(maps).forEach(k => {
  const chunkName = (k.match(/^js\/(.*)\.js$/) || [])[1]
  if (chunkName) {
    const chunk = chunkName.split('.')
    /*
    js/home-page[.hash]?.js
    css/home-page[.other hash]?.css
    */
    if(chunk.length > 1) chunk.pop() // удалим hash из чанка
    const sources = (maps[k].sources || [])
      .map(v => (v.match(/^webpack:\/\/\/\.\/(.*.vue)$/) || [])[1])
      .filter(v => !!v)
    sources.forEach(v => (chunkMap[v] = chunk.join('.')))
  }
})

const initialCSS = clientManifest.initial.filter(v => v.match(/\.css$/))
const allCSS = clientManifest.all.filter(v => v.match(/\.css$/))

console.log(initialCSS)
/*
[ 'css/app.3ff19892.css' ]
*/
console.log(allCSS)
/*
[
  'css/404.0e584305.css',
  'css/app.3ff19892.css',
  'css/home-page.5cc9953c.css',
]
*/
console.log(chunkMap)
/*
вот что будет если не убрать хеш
home-page.5393f5e1.js
home-page.5cc9953c.css
{
  'src/components/404.vue': '404',
  'src/views/HomePageModel.vue': 'home-page',
  'src/views/Home.vue': 'home-page',
}
*/

const renderer = createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve('template.html'), 'utf-8'),
  clientManifest,
})

const express = require('express')
const app = express()
app.get('*', (req, res) => {
  renderer
  	// внутри нашего приложения нужно положить это в 
  	// const store = new Vuex.Store()
  	// store.initialCSS = context.initialCSS
    .renderToString({ initialCSS, allCSS, chunkMap }, (err, html) =>
  {
    res.send(html)
  }
})

Ну, и AMP компоненты теперь рендерить очень удобно.

Мы объявляем в приложении компонент app-img

Везде его используем

app-img.vue
<template lang-"pug">
	component(:is="is" v-bind="{$attrs}")
</template>
export default {
  setup(){
    const vm = getCurrentInstance().proxy
    const is = computed(() => vm.$route.isAmp ? 'img', 'amp-img')
    return {is}
  }
}

Вуаля! Не так много кода и столько функционала :)

Буду признателен за конструктивную критику.

UPD

чуть не забыл про важную часть.

у нас же размер CSSa ограничен.

сожмем
        chain.plugin('purgecss-webpack-plugin').use(
          new PurgecssPlugin({
            paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
            content: [
              './src/**/*.html',
              './src/**/*.vue',
              './src/**/*.jsx',
            ],
            defaultExtractor: (content) => {
              const broadMatches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || []
              const innerMatches = content.match(/[^<>"'`\s.()]*[^<>"'`\s.():]/g) || []
              return broadMatches.concat(innerMatches)
            },
          })
        )
Источник: https://habr.com/ru/post/599301/


Интересные статьи

Интересные статьи

— Разве ты не понимаешь, что весь смысл новояза в том, чтобы сузить диапазон мышления? В итоге мыслепреступление станет попросту невозможным, поскольку не будет слов, которыми его можно было бы выраз...
Выгрузка пользователей из 1C ЗУП в Битрикс24 или правдивая история о том как настроить интеграцию 1С-Битрикс24 с ЗУП без 1С-ника.В жизни так бывает, причём бывает чаще чем хотелось бы, хоть в целом и ...
Совсем недавно у меня появилась необходимость разработать функционал для взаимодействия между двумя приложениями одной компании, и недолго гуглив я открыл для себя мир ме...
Те, кто собираются открывать интернет-магазин, предварительно начитавшись в интернете о важности уникального контента, о фильтрах, накладываемых поисковиками за копирование материалов с других ресурсо...
Приветствую вас (лично вас, а не всех кто это читает)! Сегодня мы: Создадим приложение (навык) Алисы с использованием нового (октябрь 2019) сервиса Yandex Cloud Functions. Настроим н...