VUE3+WEBPACK SSR实战

1,028 阅读5分钟

一 背景

  1. 项目开发初期,没有提出SEO需求,导致在最初技术设计就没考虑SSR方案,直接使用的VUE单页应用方案。
  2. 项目开发到即将上线,需求方提出了需要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解决)

结论:最终考虑到开发成本和SEO效果,选择了VUE SSR

VUESSR 实例

  • 实例地址

  • 安装

      npm install vue@next vue-server-renderer@next --save
    
    • 注意事项
      1. vue的版本与vue-server-renderer的版本一定要一致
      2. 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}`);
    });
    
    • 注意事项
      1. cookie读写,在服务器中需要读取上游服务器(接口)写入的cookie,并将其写入到下发到客户端的cookie
      2. 由于https请求是无状态,如果涉及到登录等用户信息,和识别客户端请求的需求,需要自定标识(换言之,基于浏览器指纹的识别方式在此处完全不适用,或在客户端,将浏览器指纹写入cookie)
      3. 请慎用页面级缓存(不加判断,直接返回返回,可能导致数据打死不更新的情况,进入到客户端后,会导致屏闪)
  • 入口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>