likes
comments
collection
share

vue3 服务端渲染(SSR)爬坑记、保姆级示例

作者站长头像
站长
· 阅读数 46

前言

vue2 SSR 已经被不少大佬讲烂了,vue3 SSR 除了官网 几乎没搜到其他文章,而且官网上也没讲 vuex 方面,那么我就来凑个数吧,相信大家都知道 SSR 是干嘛的,前世今生也不多说了,这儿主要结合官网把 vue3 SSR 的配置和使用过程及遇到的坑给大家排除下。

依赖

首先执行以下命令把依赖装好

npm install @vue/server-renderer // 服务端渲染最核心的包
npm install webpack-manifest-plugin webpack-node-externals webpack lru-cache -D
npm install express // 启动 node 服务的包
npm install cross-env -D // 打包 client 和 server 端会用到的参数,有的小伙伴喜欢用 
"build:server": "set SSR=1 && vue-cli-service build --dest dist/server",
但是这个 set SSR=1 在 linux 下不生效,所以还是得用 cross-env

官网 package.json 打包脚本如下
"build:client": "vue-cli-service build --dest dist/client",
"build:server": "cross-env SSR=1 vue-cli-service build --dest dist/server",
"build:ssr": "npm run build:client && npm run build:server",

路由

每个请求都需要有一个干净的路由实例,所以得提供一个路由的工厂函数。 例如下列代码

import { createRouter, RouteRecordRaw } from 'vue-router';
import Home from '../views/Home/index.vue';

export const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home,
    meta: {
      // keepAlive: true,
      depth: 1
    }
  },
  {
    path: '/popular-now/:link',
    name: 'PopularNow',
    component: () => import(/* webpackChunkName: "about" */ '../views/PopularNow/index.vue'),
    props: true,
    meta: {
      title: '我一直不明白为什么地球要',
      // keepAlive: true,
      depth: 1
    }
  },
  {
    path: '/subscription',
    name: 'Subscription',
    component: () => import(/* webpackChunkName: "about" */ '../views/Subscription/index.vue'),
    meta: {
      title: '绕着太阳转',
      // keepAlive: true,
      depth: 1
    }
  },
  {
    path: '/mediaLibrary',
    name: 'MediaLibrary',
    component: () => import(/* webpackChunkName: "about" */ '../views/MediaLibrary/index.vue'),
    meta: {
      title: '直到遇见你',
      depth: 1
    }
  },
  {
    path: '/history',
    name: 'History',
    component: () => import(/* webpackChunkName: "about" */ '../views/History/index.vue'),
    meta: {
      title: '我才蓦然明白',
      depth: 1
    }
  },
  {
    path: '/search',
    name: 'Search',
    component: () => import(/* webpackChunkName: "about" */ '../views/Search/index.vue'),
    props: route => ({ keywords: route.query.q }),
    meta: {
      title: '也许是因为有了你',
      // keepAlive: true,
      depth: 1
    }
  },
  {
    path: '/watch/:mvid',
    name: 'Watch',
    component: () => import(/* webpackChunkName: "about" */ '../views/Watch/index.vue'),
    props: true,
    meta: {
      title: '这世界才有了',
      depth: 2
    }
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import(/* webpackChunkName: "about" */ '../views/Notfound/index.vue'),
    meta: {
      title: '春夏秋冬',
      depth: 2
    }
  }
]

export default function (history: any) { // history 是其他地方传过来的,稍后会说
  return createRouter({
    history,
    routes
  })
}

// keepAlive: true
keepAlive 是注释掉的,SSR 并不支持 <keep-alive> 组件缓存,如果你原本有如下这样的代码
<router-view v-slot="{ Component }">
    <keep-alive>
      <component
        :class="[{ 'router-paper-full': !isShowSidebar }, 'router-paper']"
        :is="Component"
        v-if="$route.meta.keepAlive"
        :key="$route.name"
      />
    </keep-alive>
    <component
      :class="[{ 'router-paper-full': !isShowSidebar }, 'router-paper']"
      :is="Component"
      v-if="!$route.meta.keepAlive"
    />
</router-view>
要支持 SSR 请将 <keep-alive> 代码块干掉,不然用户在切换页面时会报如下错误,意思就是新节点插入位置错误

vue3 服务端渲染(SSR)爬坑记、保姆级示例 对啦,SSR 也不支持指令,所以你有用到指令的话,请将指令拆掉,改为 util 方式实现,例如:

// util.ts
/**
 * 加载滑动事件(原本封装在 autoLoading 指令里,但 ssr 不支持,故封装在此)
 */
const loadScrollEvent = function (el: HTMLElement, binding: any) {
    window.addEventListener(
        "scroll",
        function () {
            const temp: any = el.lastElementChild?.previousElementSibling;
            if (temp) {
                const a = temp.offsetTop + temp.offsetHeight; // 当前元素倒第二个子元素(倒第一个为 RotateLoading)与父元素顶部距离 + 元素高度

                const b =
                    document.documentElement.clientHeight +
                    (el.parentElement?.parentElement?.scrollTop || 0); // 可视区窗口高度 + 滚动距离

                if (binding.value.commandAutoLoading.isCommand && a - 100 <= b) {
                    binding.value.autoLoading();
                }
            }
        },
        true
    );
}
要使用的地方把 dom 放进去就行啦
 loadScrollEvent(this.$refs.commentList as any, {
   value: {
      commandAutoLoading: this.commentListAutoLoading,
      autoLoading: this.commentListLoading,
   },
 });

vuex

依旧要提供一个工厂函数,例如:

import { createStore } from 'vuex';
import HistoryMv from '../store/modules/HistoryMv';
import Subscription from '../store/modules/Subscription';
import LikedMv from '../store/modules/LikedMv';
import TopProgressBar from '../store/modules/TopProgressBar';
import HomeMv from '../store/modules/HomeMv';
import PopularNowMv from '../store/modules/PopularNowMv';
import CloudSearchMv from '../store/modules/CloudSearchMv';
import WatchMv from '../store/modules/WatchMv';

export default function () {

  // 必须重置各项数据,否者会共享这些命名空间下的 state
  HistoryMv.state.historyMvList = [];
  Subscription.state.artistList = [];
  LikedMv.state.likedMvList = [];
  TopProgressBar.state.start = false;
  HomeMv.state.mvList = [];
  PopularNowMv.state.mvList = [];
  CloudSearchMv.state.mvList = [];
  WatchMv.state = {
    simiMvList: [],
    artistMvList: [],
    likedCount: 0,
    mv: null,
    artistDetail: null,
    commentMv: {
      hotComments: [],
      comments: [],
      total: 0,
      more: false,
    },
    mvUrl: '',
    mvUrlErrorMsg: ''
  }

  const store = createStore({
    modules: {
      HistoryMv,
      Subscription,
      LikedMv,
      TopProgressBar,
      HomeMv,
      PopularNowMv,
      CloudSearchMv,
      WatchMv
    }
  })

  try {
    if (window && (window as any).__INITIAL_STATE__) {
      store.replaceState((window as any).__INITIAL_STATE__);
    }
  } catch (error) {
    console.log('有一个错', error.message);
  }


  return store;

}

注意啦~~~

如果用到 vuex 的命名空间,一定要重置各项数据,否者会共享这些命名空间下的 state !

并且在这个工厂函数中去获取了 node 服务器传过来的 (window as any).INITIAL_STATE 里面的 store 数据。

entry-client.js

客户端入口,该干啥就干啥啦,例如:

import { createSSRApp } from 'vue'
import { createWebHistory } from 'vue-router'
import App from './App.vue'
import createRouter from './router-ssr'
import createStore from './store-ssr'
import { filters } from './share/filters';
import './assets/fonts/iconfont/iconfont.css';
import './assets/fonts/iconfont/iconfont.js';

// 针对客户端的启动逻辑......

const app = createSSRApp(App)

const router = createRouter(createWebHistory('loutube')) // 上文说到的 history 来源
const store = createStore()

app.config.globalProperties.$filters = filters;

app.use(router).use(store)

router.isReady().then(() => {
    app.mount('#app')
})

entry-server.js

服务端入口,例如:

import { createSSRApp } from 'vue'
import { createMemoryHistory } from 'vue-router'
import App from './App.vue'
import createRouter from './router-ssr'
import createStore from './store-ssr'
import { filters } from './share/filters';
import './assets/fonts/iconfont/iconfont.css';
// import './assets/fonts/iconfont/iconfont.js'; 一定要注释掉,服务端用不上字体的 js 文件,
导入的话会报错 windowdocument 找不到之类的错误。

export default function () {
    const app = createSSRApp(App)

    const router = createRouter(createMemoryHistory('loutube')) // 上文说到的 history 来源
    const store = createStore()
  
    app.config.globalProperties.$filters = filters;
  
    app.use(router).use(store)
  
    return {
      app,
      router,
      store
    }
}

vue.config.js

打包配置文件,例如:

const { WebpackManifestPlugin } = require('webpack-manifest-plugin')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack')
// const emptyWebpackBuildDetailPlugin = require("empty-webpack-build-detail-plugin")
const path = require('path')

if (process.env.COMMON) { // 如果不是 SSR 运行方式
  return console.log('退出 vue.config.js -----');
}

module.exports = {
  chainWebpack: webpackConfig => {
    // 我们需要禁用 cache loader,否则客户端构建版本会从服务端构建版本使用缓存过的组件
    webpackConfig.module.rule('vue').uses.delete('cache-loader')
    webpackConfig.module.rule('js').uses.delete('cache-loader')
    webpackConfig.module.rule('ts').uses.delete('cache-loader')
    webpackConfig.module.rule('tsx').uses.delete('cache-loader')

    if (!process.env.SSR) {
      // 将入口指向应用的客户端入口文件
      webpackConfig
        .entry('app')
        .clear()
        .add('./src/entry-client.js')
      return
    }

    // 将入口指向应用的服务端入口文件
    webpackConfig
      .entry('app')
      .clear()
      .add('./src/entry-server.js')

    // 这允许 webpack 以适合于 Node 的方式处理动态导入,
    // 同时也告诉 `vue-loader` 在编译 Vue 组件的时候抛出面向服务端的代码。
    webpackConfig.target('node')
    // 这会告诉服务端的包使用 Node 风格的导出
    webpackConfig.output.libraryTarget('commonjs2')

    webpackConfig
      .plugin('manifest')
      .use(new WebpackManifestPlugin({ fileName: 'ssr-manifest.json' }))

    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // 将应用依赖变为外部扩展。
    // 这使得服务端构建更加快速并生成更小的包文件。

    // 不要将需要被 webpack 处理的依赖变为外部扩展
    // 也应该把修改 `global` 的依赖 (例如各种 polyfill) 整理成一个白名单
    webpackConfig.externals(nodeExternals({ allowlist: /\.(css|vue)$/ }))

    webpackConfig.optimization.splitChunks(false).minimize(false)

    webpackConfig.plugins.delete('preload')
    webpackConfig.plugins.delete('prefetch')
    webpackConfig.plugins.delete('progress')
    webpackConfig.plugins.delete('friendly-errors')

    webpackConfig.plugin('limit').use(
      new webpack.optimize.LimitChunkCountPlugin({
        maxChunks: 1
      })
    )

    // webpackConfig.plugin('emptyWebpackBuildDetailPlugin').use(
    //   emptyWebpackBuildDetailPlugin, [{
    //     path: path.join(process.cwd(), 'log'),
    //     filename: 'compile-log.md'
    //   }]
    // )
  }
}

注意啦~~~

如果打包过程中报错却未显示错误信息,可以使用 empty-webpack-build-detail-plugin 插件记录错误日志,比如你在 SSR 项目中坚持使用指令,那么它会狠狠的不给你错误信息,根本就不给反馈好吧。。。╮(╯▽╰)╭

同构

// server.js
const path = require('path')
const express = require('express')
const fs = require('fs')
const { renderToString } = require('@vue/server-renderer')
const manifest = require('../dist/server/ssr-manifest.json')

const server = express()

const appPath = path.join(__dirname, '../dist', 'server', manifest['app.js'])
const createApp = require(appPath).default

server.use('/img', express.static(path.join(__dirname, '../dist/client', 'img')))
server.use('/js', express.static(path.join(__dirname, '../dist/client', 'js')))
server.use('/css', express.static(path.join(__dirname, '../dist/client', 'css')))
server.use(
  '/favicon.ico',
  express.static(path.join(__dirname, '../dist/client', 'favicon.ico'))
)

// 此处做了个简单的页面缓存
const LRU = require('lru-cache')
const microCache = new LRU({
  max: 100,
  maxAge: 1000 * 60 * 60
})


server.get('*', async (req, res) => {
  const hit = microCache.get(req.url)
  if (hit) {
    return res.send(hit)
  }

  const { app, router, store } = createApp()

  await router.push(req.url.replace('/loutube', ''))
  await router.isReady()

  const matchedComponents = router.currentRoute.value.matched;

  // 主动触发所有匹配组件的 asyncData 函数
  Promise.all(matchedComponents.map(Component => {
    if (Component.components.default.methods.asyncData) {
      return Component.components.default.methods.asyncData(
        store,
        router.currentRoute
      );
    }
  })).then(async () => {
    const appContent = await renderToString(app);

    fs.readFile(path.join(__dirname, '../dist/client/index.html'), (err, html) => {
      if (err) {
        throw err
      }

      html = html
        .toString()
        .replace('<div id="app">', `<div id="app">${appContent}`)
        .replace(
          '</script>',
          `</script><script type="application/javascript">window.__INITIAL_STATE__=${JSON.stringify(store.state)}</script>`
        )
      res.setHeader('Content-Type', 'text/html')
      res.send(html)
      microCache.set(req.url, html)
    })
  }).catch(error => {
    console.error(error);
    res.sendStatus(500)
  });
})

server.listen(8080)

注意啦~~~

这里要自己主动将 store 放进 html 里

.replace(
      '</script>',
      `</script><script type="application/javascript">window.__INITIAL_STATE__=${JSON.stringify(store.state)}</script>`
)

组件 asyncData()

// 最普通的用法也就这样了,在这里面去触发 action 请求接口
asyncData(store: any, route: any) {
    return store.dispatch("HomeMv/getMvListRequest", {
      limit: 24,
      loadMoreCount: 0,
    });
}

// 如果业务比较复杂的话,你也许需要这样写
asyncData(store: any, route: any) {
    return Promise.all([
      this.getMvUrl(store, route),
      this.getMvLikedCount(store, route),
      this.getMvDetail(store, route).then(async () => {
        await this.getArtistDetail(store, route);
        await this.getArtistMvList(store, route);
        await store.dispatch("HistoryMv/addHistoryMv", store.state.WatchMv.mv);
      }),
      this.getCommentMvList(store, route),
    ]).then();
}

// 列出上面使用的其中一个函数
async getMvUrl(store?: any, route?: any) {
    store = store || this.$store; // 至少要拿到一个 store 吧,毕竟也不是只有 asyncData 函数会用到 getMvUrl

    const mvid = this.mvUrlState
      ? this.mvUrlState.queryParams.mvid
      : route.value.params.mvid;

    await store.dispatch("WatchMv/getMvUrlRequest", {
      id: mvid,
    });
}

promise 这一块需要你们自己去研究哈,这还不掌握,内卷的资格都没有。。。╮(╯▽╰)╭

最后

也许你的各个文件路径跟我展示的示例代码不一样而报错,请你仔细排查下。。。 对啦,在列下我的脚本

"scripts": {
    "serve": "cross-env COMMON=1 vue-cli-service serve",
    "build": "cross-env COMMON=1 vue-cli-service build",
    "lint": "vue-cli-service lint",
    "build:client": "vue-cli-service build --dest dist/client",
    "build:server": "cross-env SSR=1 vue-cli-service build --dest dist/server",
    "build:ssr": "npm run build:client && npm run build:server",
    "dev:serve": "cross-env WEBPACK_TARGET=node node ./server/server.js",
    "dev": "concurrently \"npm run serve\" \"npm run dev:serve\" "
},

那么我们就使用命令打包吧

npm run build:ssr

通过 node 运行打包好的文件

node server.js
转载自:https://juejin.cn/post/7037321595311882277
评论
请登录