SSR 原理

手动搭建 SSR 项目

安装依赖

  1. webpack:

    • webpack
    • webpack-cli
    • webpack-dev-server
    • webpack-merge
  2. vue:

    • vue
    • vue-loader
    • vue-template-compiler
    • vue-server-renderer
  3. js:

    • babel-loader
    • @babel/core
    • @babel/preset-env
  4. css:

    • vue-style-loader
    • css-loader
  5. other:

    • express
    • html-webpack-plugin

搭建 vue 开发环境

  1. /public/index.html 打包模板文件

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div id="app"></div>
    </body>
    </html>
    
  2. /src/app.vue 客户端根组件

    <template>
      <div id="app">
        <h1>我是一个vue组件</h1>
        <demo />
      </div>
    </template>
    
    <script>
    import demo from './components/demo.vue';
    export default {
      components: {
        demo
      }
    }
    </script>
    
    <style>
    
    </style>
    
  3. /src/components/demo.vue 自定义组件

    <template>
      <div>
        <input type="text" v-model="val">
        <p>输入框中的内容为:{{val}}</p>
        <div class="button" @click="show">click me</div>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          val: 'SSR'
        }
      },
      methods: {
        show() {
          alert(this.val);
        }
      }
    }
    </script>
    
    <style scoped>
      .button {
        width: 100px;
        height: 30px;
        background: #fac;
        text-align: center;
        line-height: 30px;
        border: 1px solid;
        border-radius: 5px;
      }
    </style>
    
  4. /src/main.js 客户端入口文件

    import Vue from 'vue';
    import App from './app.vue';
    
    new Vue({
      el: '#app',
      render: h => h(App),
    });
    
  5. /webpack.config.js 客户端配置文件

    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const { resolve } = require('path');
    const { VueLoaderPlugin } = require('vue-loader');
    
    module.exports = {
      entry: './src/main.js',
      output: {
        filename: 'bundle.js',
        path: resolve('./dist')
      },
      module: {
        rules: [
          {
            test: /.vue$/,
            use: 'vue-loader',
          },
          {
            test: /.js$/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-env']
              }
            },
            exclude: /node_modules/
          },
          {
            test: /.css$/,
            use: ['vue-style-loader', 'css-loader']
          }
        ]
      },
      plugins: [
        new VueLoaderPlugin(),
        new HtmlWebpackPlugin({
          filename: 'index.html',
          template: resolve('./public/index.html')
        })
      ]
    }
    
  6. 运行 npx webpack-dev-server 启动服务

搭建服务端环境

  1. /src/main.js 入口文件

    import Vue from 'vue';
    import App from './app.vue';
    
    // 由于服务端渲染不需要 el 这个属性,则将这个封装成工厂函数,每次访问服务都是一个新的实例
    export default function () {
      const app = new Vue({
        el: '#app',
        render: h => h(App),
      });
      return { app }
    }
    
  2. /src/entry/client.entry.js 创建客户端入口文件

    import createApp from '../main.js';
    
    const { app } = createApp();
    app.$mount('#app');
    
  3. /src/entry/server.entry.js 创建服务端入口文件

    import createApp from '../main.js';
    
    export default function () {
      const { app } = createApp();
      return app;
    }
    
  4. 配置文件

    • 根据 client.entry.js、server.entry.js 两个入口重新配置配置文件;
    • 将之前的配置文件重构;
    JavaScript
    JavaScript
    JavaScript
    // /webpack/webpack.base.js
    const { resolve } = require('path');
    const { VueLoaderPlugin } = require('vue-loader');
    
    module.exports = {
      output: {
        filename: '[name].bundle.js',
        path: resolve('./dist'),
        publicPath: '/'
      },
      module: {
        rules: [
          {
            test: /.vue$/,
            use: 'vue-loader',
          },
          {
            test: /.js$/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-env']
              }
            },
            exclude: /node_modules/
          },
          {
            test: /.css$/,
            use: ['vue-style-loader', 'css-loader']
          }
        ]
      },
      plugins: [
        new VueLoaderPlugin(),
      ]
    }
    
    // /webpack/webpack.client.js
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    const { default: merge } = require("webpack-merge");
    const base = require('./webpack.base.js');
    const { resolve } = require('path');
    const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
    
    module.exports = merge(base, {
      entry: {
        'client': './src/entry/client.entry.js'
      },
      plugins: [
        new VueSSRClientPlugin(),
        new HtmlWebpackPlugin({
          filename: 'index.html',
          template: resolve('./public/index.html')
        })
      ]
    });
    
    // /webpack/webpack.server.js
    const { merge } = require('webpack-merge');
    const base = require('./webpack.base.js');
    const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
    
    module.exports = merge(base, {
      entry: {
        server: './src/entry/server.entry.js'
      },
      output: {
        libraryTarget: 'commonjs2',
        // 设置了该选项后
        // module.exports = (() => {
        //   return { xxx };
        // })());
      },
      target: 'node', // 运行环境
      plugins: [
        new VueSSRServerPlugin()
      ]
    });
    
  5. 配置 package.json 脚本命令

    "scripts": {
      "dev": "webpack-dev-server --config ./webpack/webpack.client.js --mode development",
      "build:client": "webpack --config ./webpack/webpack.client.js --mode production",
      "build:server": "webpack --config ./webpack/webpack.server.js --mode production"
    },
    
  6. /server/index.js 服务端

    JavaScript
    HTML
    const express = require('express');
    const server = express();
    const fs = require('fs');
    const { resolve } = require('path');
    
    const { createBundleRenderer } = require('vue-server-renderer'); // 可以帮我们自动实现客户端激活(添加 id="app" 和自动引入 client.bundle.js)
    const serverBundle = require('../dist/vue-ssr-server-bundle.json'); // 服务端打包内容
    const clientManifest = require('../dist/vue-ssr-client-manifest.json'); // manifest 可以预加载
    
    // 开启静态资源服务器,用于客户端激活的时候,获取 客户端的打包内容 client.bundle.js
    server.use(express.static(resolve('../dist'), { index: false }));
    
    
    server.get('*', async (req, res) => {
      try {
        // 2. 创建渲染器
        const render = createBundleRenderer(serverBundle, {
          template: fs.readFileSync('./index.ssr.html', 'utf-8'),
          clientManifest
        });
        const html = await render.renderToString();
        res.send(html)
        // 3. 利用渲染器将vue实例转化成html字符串
      } catch (error) {
        console.log(error);
        res.status(500).send('服务器错误');
      }
    
      // res.send('hello');
    });
    
    server.listen(12306, () => console.log('server is run at 12306'));
    
    <!-- /server/index.ssr.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <!--vue-ssr-outlet-->
      <!-- <script src="/client.bundle.js"></script> -->
    </body>
    </html>
    
  7. nodemon index.js 进入 server 文件夹 启动服务端服务

路由处理

  1. 安装 vue-router

    npm i vue-router
    
  2. /src/router.js 路由表

    import Vue from 'vue';
    import VueRouter from 'vue-router';
    
    Vue.use(VueRouter);
    
    export default function () {
      const router = new VueRouter({
        mode: 'history',
        routes: [
          {
            path: '/',
            component: () => import('./components/home.vue')
          },
          {
            path: '/demo',
            component: () => import('./components/demo.vue')
          }
        ]
      });
      return router;
    }
    
  3. /src/main.js 客户端引入 vue router

    import Vue from 'vue';
    import App from './app.vue';
    import createRouter from './router';
    
    export default function () {
      const router = createRouter();
    
      const app = new Vue({
        render: h => h(App),
        router,
      });
      return { app, router }
    }
    
  4. 客户端相关组件

    HTML
    HTML
    <!-- app.vue -->
    <template>
      <div id="app">
        <h1>我是一个vue组件</h1>
        <router-link to="/">首页</router-link>
        <router-link to="/demo">Demo</router-link>
        <router-view></router-view>
      </div>
    </template>
    
    <script>
    export default {};
    </script>
    
    <style></style>
    
    <!-- /src/components/home.vue -->
    <template>
      <div>
        <h1>我是Home页面</h1>
      </div>
    </template>
    
    <script>
    export default {
    };
    </script>
    
    <style></style>
    
  5. /server/index.js 服务端修改

    const express = require('express');
    const server = express();
    const fs = require('fs');
    const { resolve } = require('path');
    const { createBundleRenderer } = require('vue-server-renderer');
    const serverBundle = require('../dist/vue-ssr-server-bundle.json');
    const clientManifest = require('../dist/vue-ssr-client-manifest.json');
    
    server.use(express.static(resolve('../dist'), { index: false }));
    
    
    server.get('*', async (req, res) => {
      // /demo, req.url /demo
      try {
        const url = req.url;
        // 2. 创建渲染器
        const render = createBundleRenderer(serverBundle, {
          template: fs.readFileSync('./index.ssr.html', 'utf-8'),
          clientManifest
        });
        const html = await render.renderToString({url});
        res.send(html)
        // 3. 利用渲染器将vue实例转化成html字符串
      } catch (error) {
        console.log(error);
        if (error.code == 404) {
          res.status(404).send('页面去火星了,找不到了,404啦');
          return;
        }
        res.status(500).send('服务器错误');
      }
    
      // res.send('hello');
    });
    
    server.listen(12306, () => console.log('server is run at 12306'));
    
  6. /src/entry/server.entry.js 服务端入口引入 vue router

    import createApp from '../main.js';
    
    export default function (ctx) {
      // 有可能是异步路由,则使用 promise
      return new Promise((resolve, reject) => {
        const { app, router, store } = createApp();
        router.push(ctx.url);
    
        router.onReady(() => {
          // 判断当前路由下是否存在组件
          const matchedComponents = router.getMatchedComponents();
          if (matchedComponents.length == 0) {
            return reject({code: 404});
          }
          resolve(app);
        }, reject);
      });
    }
    
  7. nodemon index.js 进入 server 文件夹 启动服务端服务

通过 vuex 数据预取

  1. 安装 vuex

    npm i vuex
    
  2. /src/store.js 创建 store.js

    // 数据预取
    import Vue from 'vue';
    import Vuex from 'vuex';
    Vue.use(Vuex);
    
    export default function () {
      const store = new Vuex.Store({
        state: {
          name: '',
        },
        mutations: {
          setName(state, val) {
            state.name = val;
          }
        },
        actions: {
          getName({ commit }) {
            return new Promise(resolve => {
              setTimeout(function () {
                resolve('SSR');
              }, 300);
            }).then(val => {
              commit('setName', val);
            })
          }
        }
      });
      return store;
    }
    
  3. /src/main.js 客户端引入 vuex

    import Vue from 'vue';
    import App from './app.vue';
    import createRouter from './router';
    import createStore from './store';
    
    export default function () {
      const router = createRouter();
      const store = createStore();
      
      const app = new Vue({
        render: h => h(App),
        router,
        store
      });
      return { app, router, store }
    }
    
  4. home.vue 使用 vuex

    <template>
      <div>
        <h1>我是Home页面</h1>
        <div>欢迎,{{ name }}</div>
      </div>
    </template>
    
    <script>
    import { mapState } from 'vuex';
    export default {
      computed: {
        ...mapState(['name']),
      },
      created() {
        this.$store.dispatch('getName');
      },
      // 服务端调用,需要在服务端入口文件中处理
      asyncData(store) {
        return store.dispatch('getName');
      },
    };
    </script>
    
    <style></style>
    
  5. /src/entry/server.entry.js 服务端入口中通过 asyncData 预取数据存储在 window.INITIAL_STATE

    import createApp from '../main.js';
    
    export default function (ctx) {
      return new Promise((resolve, reject) => {
        const { app, router, store } = createApp();
        router.push(ctx.url);
        router.onReady(() => {
          // 判断当前路由下是否存在组件
          const matchedComponents = router.getMatchedComponents();
          if (matchedComponents.length == 0) {
            return reject({code: 404});
          }
    
          // 判断组件中是否存在 asyncData 方法,有则执行 asyncData
          Promise.all(matchedComponents.map(c => {
            if (c.asyncData) {
              return c.asyncData(store)
            }
          })).then(() => {
            // 注入到 window.__INITIAL_STATE__ 该全局属性上
            ctx.state = store.state;
            resolve(app);
          }).catch(reject);
          // resolve(app);
        }, reject)
      });
    }
    
  6. /src/entry/client.entry.js 客户端更新 store

    import createApp from '../main.js';
    
    const { app, store } = createApp();
    app.$mount('#app');
    
    // 如果存在 window.__INITIAL_STATE__ 说明服务端通过 vuex 预取数据了,则需要客户端更新 store
    if (window.__INITIAL_STATE__) {
      store.replaceState(window.__INITIAL_STATE__);
    }
    
  7. nodemon index.js 进入 server 文件夹 启动服务端服务

打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

中午好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. SSR 原理
  2. 2. 手动搭建 SSR 项目
    1. 2.1. 安装依赖
    2. 2.2. 搭建 vue 开发环境
    3. 2.3. 搭建服务端环境
    4. 2.4. 路由处理
    5. 2.5. 通过 vuex 数据预取