一 背景
- 项目开发初期,没有提出SEO需求,导致在最初技术设计就没考虑SSR方案,直接使用的VUE单页应用方案。
- 项目开发到即将上线,需求方提出了需要SEO,在评估改动方案后,敲定了使用VUESSR。
二 技术选型过程以及思考
接到改造任务之后,结合当前项目实际情况,提出如下三种方案
- 使用服务端和客户端异构渲染的方式,满足部分SEO需求。
- 服务端使用模板文件,直接返回模板文件,带有SEO的关键字以及静态模板
- 优势
- 前端无需介入,由后台根据请求来源返回对应模板,比较适合已经开发完成准备上线状态的修改
- 缺点
- 无法动态渲染数据,只能渲染固定页面,然后装载SPA
- 久不更新,SEO收录权重会被降低
- 使用NUXTJS
- 使用NUXTJS框架,重构项目的前端部分
- 优势
- 框架简单,且成熟,容易上手
- 可以渲染动态数据
- 缺点
- 项目改造成本高,几乎前端部分需要整体重构
- 路由不能自定义配置,需要按照固定规则匹配文件,灵活性不高
- 使用 phantomjs-prebuilt
- 使用phantomjs-prebuilt,搭建无图浏览器
- 优势
- 前端代码无需修改,只需要搭建中间node服务
- 缺点
- 由于是无图浏览器渲染内容(实际上还是客户端渲染,执行的还是SPA的逻辑),有可能被爬虫屏蔽
- 需要额外搭建NODE服务
- 目前该插件已停止维护(2022)
- 使用VUE SSR
- 使用VUE SSR搭建服务端渲染
- 优势
- VUE官方推荐,教程和实践示例完善
- 项目成员都有相关预研
- 动态实时渲染请求页面数据
- 路由以及渲染内容可配置化程度比较高,灵活
- 缺点
- 需要配合node服务器,稳定性存疑(可以使用PM2+多机集群解决)
- 每次请求新页面,都是新的VUE对象,无法记录请求状态(可以使用写COOKIE的方式记录token)
- 高并发假死问题(同缺点1解决)
- 优势
- 使用VUE SSR搭建服务端渲染
结论:最终考虑到开发成本和SEO效果,选择了VUE SSR
VUESSR 实例
-
安装
npm install vue@next vue-server-renderer@next --save- 注意事项
- vue的版本与vue-server-renderer的版本一定要一致
- vue-server-renderer只能在node环境中使用
- 注意事项
-
vue主入口
main.js
import { createApp } from 'vue' import App from './App' import router from './router' import store from './store' const app = createApp(App) // 使用vue路由和vuex需要引入vuex和vue router app.use(store) app.use(router) export { app, router, store }- 注意事项
1.使用服务端渲染,不能采用SPA模式的单个VUE实例,需要在每次访问的时候,重新createApp,产生新的vue实例,否则会导致数据串台
- 注意事项
-
服务端渲染入口
import { app, router, store } from './main' export default context => { // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个Promise // 以便服务器能够等待所有的内容在渲染前就已经准备就绪 return new Promise((resolve, reject) => { router.push(context.url) router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,执行reject函数,并返回404 if (!matchedComponents.length) { // eslint-disable-next-line prefer-promise-reject-errors return reject({ code: 404 }); } }) }) } -
客户端渲染入口
import { app, router, store } from './main' // 在组件更新前执行数据预取函数 app.mixin({ beforeRouteUpdate(to,from,next) { const { asyncData } = this.$options; if (asyncData) { asyncData({ store: this.$store, route: to }).then(next).catch(next); } else { next(); } } }) // 客户端挂载app之前,store状态替换 if (window.__INITIAL_STATE__) { // 存在本地store的情况下,使用本地的store const localStore = JSON.parse(sessionStorage.getItem('vuex')); window.__INITIAL_STATE__ store.replaceState(localStore || window.__INITIAL_STATE__); } // 数据预处理,路由导航跳转前,解析数据 router.onReady(() => { // 添加路由钩子函数,用于处理asyncData // 在初始路由resolve之后执行,以便我们不会二次预取已有的数据 // 使用 `router.beforeResolve()`,以确保所有异步组件都resolve router.beforeResolve((to, from, next) => { const matchedComponents = router.getMatchedComponents(to); const preMatchComponents = router.getMatchedComponents(from); const actived = matchedComponents.filter((component, i) => component !== preMatchComponents[i]); // 我们只关心非预渲染的组件 // 所以我们对比它们,找出两个匹配列表的差异列表 const activatedAsyncHooks = actived.map(component => component && component.asyncData).filter(Boolean); if (!activatedAsyncHooks.length) { return next(); } Promise.all( activatedAsyncHooks.map(hook => { hook({ store, route: to }); }) ).then(() => { next(); }) .catch(next); }); app.$mount('#app'); }); -
express服务器以及相关配置
const fs = require('fs'); const path = require('path'); const LRU = require('lru-cache'); const express = require('express'); const proxyMiddleWare = require('http-proxy-middleware'); const compression = require('compression'); const microcache = require('route-cache'); const resolve = file => path.resolve(__dirname, file); const { createBundleRenderer } = require('vue-server-renderer'); const cookieParser = require('cookie-parser'); const isProd = process.env.NODE_ENV === 'production'; const devServer = process.env.NODE_ENV === 'devServer'; const useMicroCache = process.env.MICRO_CACHE !== 'false'; const target = require('./config/romteAddress'); const serverInfo = `express/${require('express/package.json').version} ` + `vue-server-renderer/${require('vue-server-renderer/package.json').version}`; const app = express(); let renderer; let readyPromise; // SSR模板文件 const templatePath = resolve('./index.html'); if (isProd || devServer) { // In production: create server renderer using template and built server bundle. // The server bundle is generated by vue-ssr-webpack-plugin. const template = fs.readFileSync(templatePath, 'utf-8'); const bundle = require('./dist/vue-ssr-server-bundle.json'); // The client manifests are optional, but it allows the renderer // to automatically infer preload/prefetch links and directly add <script> // tags for any async chunks used during render, avoiding waterfall requests. const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createRenderer(bundle, { template, clientManifest }); } else { // In development: setup the dev server with watch and hot-reload, // and create a new renderer on bundle / index template update. readyPromise = require('./build/setup-dev-server')(app, templatePath, (bundle, options) => { renderer = createRenderer(bundle, options); }); } function createRenderer(bundle, options) { // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer return createBundleRenderer( bundle, Object.assign(options, { // for component caching cache: LRU({ max: 100, maxAge: 1000 }), // this is only needed when vue-server-renderer is npm-linked basedir: resolve('./dist'), // recommended for performance // 修改runInNewContext 值为true,使之能传递cookies透传 runInNewContext: true })); } function render(req, res) { const s = Date.now(); res.setHeader('Content-Type', 'text/html'); res.setHeader('Server', serverInfo); const handleError = err => { if (err.url) { res.redirect(err.url); } else if (err.code === 404) { res.status(404).send('404 | Page Not Found'); } else { // Render Error Page or Redirect res.status(500); res.redirect('/error?status=500'); console.error(`error during render : ${req.url}`); console.error(err.stack); } }; const context = { title: 'ssr demo', // default title url: req.url, cookies: req.cookies }; if (JSON.stringify(req.cookies) !== '{}') { res.cookie('access_token', req.cookies.access_token, { expires: new Date(new Date().getTime() + 14 * 86400000) }); res.cookie('refresh_token', req.cookies.refresh_token, { expires: new Date(new Date().getTime() + 14 * 86400000) }); } // 执行服务端渲染,返回的是服务端渲染的模板 renderer.renderToString(context, (err, html) => { console.log(context.url, err); if (err) { return handleError(err); } res.send(html); if (!isProd) { console.log(`whole request: ${Date.now() - s}ms`); }}); } const serve = (path, cache) => express.static(resolve(path), { maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0 }); app.set('trust proxy', true); app.disable('etag'); app.use(cookieParser()); app.use(compression({ threshold: 0 })); app.use('/apple-app-site-association', serve('./dist/apple-app-site-association')); app.use('/dist', serve('./dist', true)); app.use('/assets', serve('./assets', true)); app.use('/favicon.ico', serve('./favicon.ico', true)); app.use('/manifest.json', serve('./manifest.json', true)); app.use('/service-worker.js', serve('./dist/service-worker.js')); app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl)); // 允许所有域名跨域 app.all('*', function(req, res, next) { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers','Content-Type,Content-Length, Authorization, Accept,X-Requested-With'); res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS'); res.header('X-Forwarded-For', req.connection.remoteAddress); res.header('x-real-ip', req.connection.remoteAddress); if (req && req.cookies && req.cookies.access_token) { req.headers.authorization = req.cookies.access_token; } // 跨域请求CORS中的预请求 if (req.method === 'OPTIONS') { res.send(200); //让options请求快速返回 } else { next(); } }); // 服务器代理 app.use('/api', proxyMiddleWare({ // target: 'http://10.1.15.99:9501/', target: isProd ? target.prod : target.dev, // target: 'http://192.168.10.245:9501/', changeOrigin: true, onProxyReq: (proxyReq, req, res) => { proxyReq.setHeader('X-Forwarded-For', req.connection.remoteAddress); console.log('接口请求url-----', process.env.VUE_ENV, req.url, req.connection.remoteAddress); } })); app.get('*',isProd || devServer ? render : (req, res) => { readyPromise.then(() => render(req, res)); }); const port = process.env.PORT || 9990; app.listen(port, () => { console.log(`server started at localhost:${port}`); });- 注意事项
- cookie读写,在服务器中需要读取上游服务器(接口)写入的cookie,并将其写入到下发到客户端的cookie
- 由于https请求是无状态,如果涉及到登录等用户信息,和识别客户端请求的需求,需要自定标识(换言之,基于浏览器指纹的识别方式在此处完全不适用,或在客户端,将浏览器指纹写入cookie)
- 请慎用页面级缓存(不加判断,直接返回返回,可能导致数据打死不更新的情况,进入到客户端后,会导致屏闪)
- 注意事项
-
入口html模板
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> </head> <body> <div id="app"></div> <!--vue-ssr-outlet--> <!-- built files will be auto injected --> <!-- <script src="assets/lottie.min.js"></script> --> </body> </html> -
App.vue入口
此处同非SSR入口一样 -
路由页面
<template> <div> <!---- 模板内容 --> </div> </template> <scrpit> name:'demo'; // 省略其他代码 asyncData({ store, route }){ return Promise.all([ // 数据预取函数,请放入STORE中,以保证进入客户端渲染后,能被正确激活 ]) } </scrpit>