SSR原理

272 阅读3分钟

参考视频 手把手教你打造Vue SSR

nodemon 包可监听node.js文件变化,自动重启更新

目录

  • 初始化vue项目
  • 修改server端和client端webpack打包配置
  • 修改server端和client端入口文件|路由|vuex
  • asyncData服务器数据预取
  • 增加nodejs服务端启动脚本和html模板
  • head占位符

初始化新vue项目

  • ssr渲染过程一是node端输出html带dom结构,二是客户端请求js激活dom结构挂载vue特性

面向C端的项目一般都要SSR

优势是:

  • SEO搜索
  • 缩短首屏白屏时间

缺点是:

  • 有些库不支持ssr,要改造
  • 服务端需要nodejs服务,增加了维护成本
  • 写法稍不一样,需要提高技术含量
  • 服务端只有beforeCreate和created生命周期

初始化vue新项目

vue create ssrtest

装包

主要是 vue-server-renderer、express

npm install vue-server-renderer express cross-env webpack-node-externals -D
npm install vue-router vuex

注意vue2,3和vue-router版本要匹配

    "vue": "^2.6.10",
    "vue-router": "^3.2.0",
    "vuex": "^3.4.0"

修改server端和client端webpack打包配置

  • server端webpack的目的是生成vue-ssr-server-bundle.json,运行在nodejs服务端
  • client端webpack的目的是生成vue-ssr-client-manifest.json,是客户端按需加载激活前端dom结构

新建vue.config.js

vue启动的时候@vue/cli-service会自动加载加载vue.config.js配置

const TARGET_NODE = process.env.WEBPACK_TARGET === 'node';
const serverConfig = require('./vue.server.config');
const clientConfig = require('./vue.client.config');

if (TARGET_NODE) {
    module.exports = serverConfig;
} else {
    module.exports = clientConfig;
}

新建vue.client.config.js

vue官方提供了ssr的插件vue-server-renderer/client-plugin

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

module.exports = {
  configureWebpack: () => ({
    entry: `./src/client-entry.js`,
    devtool: "source-map",
    target: "web",
    plugins: [new VueSSRClientPlugin()],
  }),
  chainWebpack: (config) => {
    config.plugins.delete("html");
    config.plugins.delete("preload");
    config.plugins.delete("prefetch");
  },
};

新建vue.server.config.js

vue官方提供了ssr的插件vue-server-renderer/server-plugin

const nodeExternals = require("webpack-node-externals");
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");

module.exports = {
  css: {
    // 不提取 CSS
    extract: false,
  },
  outputDir: "serverDist",
  configureWebpack: () => ({
    // 服务器入口文件
    entry: `./src/server-entry.js`,
    devtool: "source-map",
    // 构建目标为nodejs环境
    target: "node",
    output: {
      // 构建目标加载模式 commonjs
      libraryTarget: "commonjs2",
    },
    // 跳过 node_mdoules,运行时会自动加载,不需要编译
    externals: nodeExternals({
      // 允许css文件,方便css module
      allowlist: [/\.css$/],
    }),
    // 关闭代码切割
    optimization: {
      splitChunks: false,
    },
    plugins: [new VueSSRServerPlugin()],
  }),
};

package.json修改命令

{
  "scripts": {
    "server": "npm run build && npm run build:server && node server.js",
    "dev": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server"
  },
}

修改server端和client端入口文件|路由|vuex

新建src/server-entry.js

  • 从createApp获取新vue、router、vuex对象
  • router.push(context.url)返回当前请求路由页面
  • getMatchedComponents获得当前页面组件
  • 调用asyncData方法获得接口数据存放到store中
  • vue-server-renderer库会将context.state中数据放到 window.INITIAL_STATE
import createApp from './createApp';

export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前就已经准备就绪。
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp();
        // 设置服务器端 router 的位置
        router.push(context.url)
        // onReady 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }
            // 对所有匹配的路由组件调用 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoute
                    });
                }
            })).then(() => {
                // 状态传递给renderer的上下文,方便后面客户端激活数据
                context.state = store.state;
                resolve(app)
            }).catch(reject);
        }, reject);
    })
}

新建src/client-entry.js

  • 从createApp获取新vue、router、vuex对象
  • 根据 window.INITIAL_STATE 加载数据
  • 挂载激活客户端dom结构
import Vue from 'vue';
import createApp from './createApp';

Vue.config.productionTip = false

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

if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
    app.$mount('#app', true);
});

新建src/createApp.js

  • 获取新router和vuex对象,返回新vue实例
import Vue from "vue";
import App from "./App";
import createRouter from "./createRouter";
import createStore from "./createStore";

export default function createApp(context) {
  const router = createRouter();
  const store = createStore();
  const app = new Vue({
    router,
    store,
    render: (h) => h(App),
  });
  return {
    router,
    store,
    app,
  };
}

新建 src/createRouter.js

  • 返回路由创建函数
import Vue from 'vue';
import Router from 'vue-router';
import Home from './Home';

Vue.use(Router)
const routes = [{
    path: '/',
    name: 'Home',
    component: Home
}];
export default function createRouter() {
    return new Router({
        mode: 'history',
        routes
    })
}

创建src/createStore.js

返回新vuex实例

import Vue from 'vue';
import Vuex from 'vuex';
import {fetchItem} from './api';

Vue.use(Vuex);

export default function createStore() {
    return new Vuex.Store({
        state: {
            item: {}
        },
        actions: {
            fetchItem({ commit }, id) {
                return fetchItem(id).then(item => {
                    commit('setItem', item);
                });
            }
        },
        mutations: {
            setItem(state, item) {
                state.item = item;
            }
        }
    })
}

修改App.vue

<template>
    <div id="app">
        <router-link :to="{path: '/'}">Home</router-link>
        <router-link :to="{path: '/about'}">About</router-link>
        <router-view />
    </div>
</template>

组件asyncData预请求数据

新建src/Home.vue

<template>
  <div>
    <div>id: {{ item.id }}</div>
    <div>name: {{ item.name }}</div>
  </div>
</template>

<script>
export default {
  asyncData({ store, route }) {
    // 触发 action 后,会返回 Promise
    return store.dispatch("fetchItem", route.query.id || 2);
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item() {
      return this.$store.state.item;
    },
  },
};
</script>

新建 src/api.js

export function fetchItem(id) {
    const items = [
        { name: 'item1', id: 1 },
        { name: 'item2', id: 2 },
        { name: 'item3', id: 3 }
    ];
    const item = items.find(i => i.id == id);
    return Promise.resolve(item);
}

增加nodejs服务端启动脚本和html模板

新建server.js的node启动文件

const express = require('express');
const {createBundleRenderer} = require('vue-server-renderer');
const path = require('path');
const fs = require('fs');
const app = express();
const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');
const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json');
const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, 'utf-8'));
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
    runInNewContent: false,
    template,
    clientManifest
});

app.use(express.static(path.resolve(process.cwd(), 'dist'),{index:false}));

app.get('*', function(req, res) {
    const context = {
        url: req.url
    };
    renderer.renderToString(context, (err, html) => {
        if (err) {
            console.log(err);
            res.send('500 server error');
            return;
        }
        res.send(html);
    })
});

app.listen(3001, () => {
    console.log('listen:3001');
});

新建index.html模板

  • 固定 vue-ssr-outlet 占位符
<html>
  <head>
    <title>SSR</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

运行效果

可以看到运行请求源码中是有html结构的

image.png

head占位符

head占位符

  • context.任意串:讲作为占位符在html模板中使用 {{title}} {{meta}} {{{meta}}}三个括号不转义
  • context.head:(字符串)将会被作为 HTML 注入到页面的头部 (head) 里。
  • context.styles:(字符串)内联 CSS,将以 style 标签的形式注入到页面头部。注意,如过你使用了 vue-loader + vue-style-loader 来处理组件 CSS,此属性会在构建过程中被自动生成。
  • context.state:(对象)初始 Vuex store 状态,将以 window.__INITIAL_STATE__ 的形式内联到页面