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

Kate

Administrator
Команда форума
Довольно давненько подружил свои приложения с гуглом.
Основная идея была - не создавая новых шаблонов, получить все страницы сайта 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, allCSS, chunkMap } = ssrContext
const cssSources = [...initialCSS]

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)
},
})
)


 
Сверху