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

от автора

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

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

Основная идея была — не создавая новых шаблонов, получить все страницы сайта 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)             },           })         ) 


ссылка на оригинал статьи https://habr.com/ru/post/599301/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *