软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

Vue CLI构建Vue SSR

Vue SSR官方的指导教程只有简单的Webpack配置,没有指导如果使用Vue CLI来构建SSR。Vue CLI构建CSR的SPA比较方便,默认不支持构建SSR。好在Vue CLI的可定制性比较强,经过一番的摸索,终于成功使用Vue CLI构建Vue SSR。

在构建SSR之前,需要按照官方的指导完成代码的改造,让SSR与CSR代码复用。

1. 代码改造

本节均按照Vue SSR官方指导来完成代码改造: main.ts、entry-client.ts、entry-server.ts。比较特殊的是:main.ts 中去除import './registerServiceWorker';,NodeJS中没有service worker,避免SSR执行时异常。

// main.ts

/* eslint-disable import/prefer-default-export */
import Vue from 'vue';
import { sync } from 'vuex-router-sync';

import App from './App.vue';
// import './registerServiceWorker';       // 不能直接导入 service worker 相关的内容!!!
import { createRouter } from './router';
import { createStore } from './store';

Vue.config.productionTip = false;

export function createApp() {
  const router = createRouter();
  const store = createStore();

  sync(store, router);

  const app = new Vue({
    router,
    store,
    render: (h) => h(App),
  });

  return { app, router, store };
}
// entry-client.ts

import { createApp } from './main';

const { app, router } = createApp();

router.onReady(() => {
  app.$mount('#app');
});
// entry-server.ts

import { createApp } from './main';
import { AsyncDataComponent } from './views/aysncParam';

export interface Context {
  url: string,
  title: 'Vue App',
  metas: `<meta name="keyword" content="vue,ssr">
          <meta name="description" content="vue srr demo">`,
  state: {
    [key: string]: any,
  },
}

export interface RespStatusCodeError extends Error {
  code: number;
  message: string;
}

export class PageNotFoundError extends Error implements RespStatusCodeError {
  code: number;

  message: string;

  constructor(...args: []) {
    super(...args);
    this.code = 404;
    this.message = 'Page not found';
  }
}

export default (context: Context) => new Promise((resolve, reject) => {
  const { app, router, store } = createApp();

  // 设置服务器端 router 的位置
  router.push(context.url);

  // 等到 router 将可能的异步组件和钩子函数解析完
  router.onReady(() => {
    const matchedComponents = router.getMatchedComponents() as AsyncDataComponent[];
    // 匹配不到的路由,执行 reject 函数,并返回 404
    if (!matchedComponents.length) {
      reject(new PageNotFoundError());
      return;
    }

    Promise.all(matchedComponents.map((Component) => {
      if (Component.asyncData) {
        return Component.asyncData({
          store,
          route: router.currentRoute,
        });
      }

      return undefined;
    })).then(() => {
      // 在所有预取钩子(preFetch hook) resolve 后,
      // 我们的 store 现在已经填充入渲染应用程序所需的状态。
      // 当我们将状态附加到上下文,
      // 并且 `template` 选项用于 renderer 时,
      // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
      context.state = store.state;

      resolve(app);
    }).catch(reject);
  }, reject);
});

2. 定义构建命令

在 package.json 中添加 build:ssr 命令:

{
  "scripts": {
    "serve": "cross-env VUE_SSR=false vue-cli-service serve --open",
    "build": "cross-env VUE_SSR=false vue-cli-service build",
    "build:ssr": "cross-env VUE_SSR=true vue-cli-service build --dest dist-ssr --formats commonjs --skip-plugins pwa,workbox"
  },
}

主要是添加 VUE_SSR 环境变量,让后面的 vue.config.js 能够识别构建目标。

3. 配置Vue CLI脚本

在项目根目录添加 vue.config.js

// vue.config.js

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');

const isClient = process.env.VUE_SSR !== 'true';
const isProd = process.env.NODE_ENV === 'production';

console.log('process.env.VUE_SSR', process.env.VUE_SSR);
console.log('isClient', isClient);

const getClientWebpackConfig = () => ({
  plugins: [
    new VueSSRClientPlugin(),
  ],
});

const getServerWebpackConfig = () => ({
  target: 'node',
  devtool: 'source-map',
  output: {
    libraryTarget: 'commonjs2',
  },
  plugins: [
    new VueSSRServerPlugin(),
  ],
  optimization: {
    minimize: false,
    runtimeChunk: false,
    splitChunks: false,
  },
});

module.exports = {
  pages: {
    index: {
      entry: isClient ? ['src/entry-client', 'src/registerServiceWorker'] : 'src/entry-server',
    },
  },
  css: {
    extract: isClient && isProd,
  },
  configureWebpack: isClient ? getClientWebpackConfig() : getServerWebpackConfig(),
};

在构建SSR时,css.extract必需设为false。否则,动态导入的模块包含CSS时,NodeJS因无document而异常。

4. SSR服务脚本

// server/index.js

/* eslint-disable  @typescript-eslint/no-var-requires, import/no-extraneous-dependencies */
const { createBundleRenderer } = require('vue-server-renderer');
const server = require('express')();

const serverBundle = require('../dist-ssr/vue-ssr-server-bundle.json');
const clientManifest = require('../dist/vue-ssr-client-manifest.json');

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐
  // template, // (可选)页面模板
  clientManifest, // (可选)客户端构建 manifest
});

server.get('*', (req, res) => {
  const context = { url: req.url };
  // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  renderer.renderToString(context, (err, html) => {
    // 处理异常……
    res.end(html);
  });
});

server.listen(8080);