Привет, хабрист!
Довольно давненько подружил свои приложения с гуглом.
Основная идея была — не создавая новых шаблонов, получить все страницы сайта 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', }, // вот с этим нужно быть очень внимательными // но без этого у нас выведется на страницу что-то такое // <style> __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/
Добавить комментарий