vue ssr 实践

夔建章
2023-12-01

技术栈

后台使用的是express,前端使用的是vue+webpack。

初始化项目并安装相关依赖

首先新建一个文件夹, npm init -y 初始化项目,然后安装相关依赖包,这里简单列一下需要安装的依赖包以及相关版本

  "dependencies": {
    "express": "^4.17.1",
    "vue": "^2.6.11",
    "vue-router": "^3.4.3",
    "vue-server-renderer": "^2.6.11",
    "vuex": "^3.5.1"
  },
  "devDependencies": {
    "@babel/core": "^7.11.1",
    "@babel/preset-env": "^7.11.0",
    "babel-loader": "^8.1.0",
    "css-loader": "^4.2.1",
    "html-webpack-plugin": "^4.3.0",
    "mini-css-extract-plugin": "^0.10.0",
    "nodemon": "^2.0.4",
    "style-loader": "^1.2.1",
    "vue-loader": "^15.9.3",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.11",
    "webpack": "^4.44.1",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0",
    "webpack-merge": "^5.1.1"
  }

编写webpack相关配置

  • base相关配置
    • 这里只需要处理后缀为cssjsvue的文件,其他的东西,如图片,字体图标这里没有使用到,所以这里不做配置。
    • 这里主要使用到了2个插件vue-loader/lib/pluginmini-css-extract-plugin
    • vue-loader/lib/plugin主要是用来处理.vue相关文件。
    • mini-css-extract-plugin主要是webpack4用来提取css的,但是vue ssr官网上的文档比较老了,官网上写的还是使用extract-text-webpack-plugin,webpack4是不支持的,而且官网上还是用到了vue-style-loader,这里我们也用不上,因为vue-style-loader无法配合extract-text-webpack-plugin把css提取出来。还有一点就是如果你是用到了vue-style-loader,但是css-loader的版本是4.x的,就需要把css-loader的参数esModule设置为false,否则会报错,这也是我在编写配置的时候发现的一个问题。
      webpack.base.js
const path = require("path");
const VuePluginLoader = require("vue-loader/lib/plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const resolve = (dir) => {
  return path.resolve(__dirname, dir);
};
module.exports = {
  output: {
    filename: "[name].bundle.js",
    path: resolve("../dist"),
  },
  resolve: {
    extensions: [".js", ".vue"],
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: "vue-loader",
        options: {
          extractCSS: true,
        },
      },
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader"
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "style.css",
      chunkFilename: "[id].css",
    }),
    new VuePluginLoader(),
  ],
};

  • client相关配置
    • 客户端的配置主要使用到了vue-server-renderer/server-plugin这个插件,用来记录客户端打包出来的相关资源,并生成一个vue-ssr-client-manifest.json文件,后期在编写后端的时候会使用到这个文件,用来自动引入相关jscss相关文件到html中,减去手动引入的烦恼。
    • html-webpack-plugin生成html文件,这个主要是在开发的时候使用到,就是不走ssr渲染的时候使用到

webpack.client.js

const { merge } = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ClientRenderPlugin = require("vue-server-renderer/client-plugin");
const path = require("path");
const baseConfig = require("./webpack.base");
const resolve = (dir) => {
  return path.resolve(__dirname, dir);
};
const config = {
  entry: { client: resolve("../src/client-entry.js") },
  plugins: [
    new ClientRenderPlugin(),
    new HtmlWebpackPlugin({
      filename: "index.html",
      template: resolve("../public/index.html"),
    }),
  ],
};
module.exports = merge(baseConfig, config);

  • server相关配置
    • 服务端的配置主要使用到了vue-server-renderer/server-plugin这个插件,根据客户端的用法差不多,但是是用来记录服务端打包出来的相关资源,并生成一个ue-ssr-server-bundle.json文件。
    • html-webpack-plugin生成html文件,这个在服务端是不需要的,我这里是为了方便,目的是为了把所有文件都放在dist文件夹中。读取文件的时候就在dist中读取即可,不用读取js的时候就在dist中,读取html在public中这么麻烦。
    • 因为打包出来的文件是给node使用的,所以我们需要指定target打包的目标为node,还有输出的模块规范为commonjs2
      webpack.server.js
const { merge } = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ServerRenderPlugin = require("vue-server-renderer/server-plugin");
const path = require("path");
const baseConfig = require("./webpack.base");
const resolve = (dir) => {
  return path.resolve(__dirname, dir);
};
const config = {
  entry: { server: resolve("../src/server-entry.js") },
  target: "node",
  output: {
    libraryTarget: "commonjs2",
  },
  plugins: [
    new ServerRenderPlugin(),
    new HtmlWebpackPlugin({
      filename: "index.ssr.html",
      template: resolve("../public/index.ssr.html"),
      inject: false,
    }),
  ],
};
module.exports = merge(baseConfig, config);

编写客户端,服务端通用代码

  • vue-router路由方面需要使用工厂函数并返回一个路由对象实例,这是因为在服务端渲染的时候,每来一次请求都需要一个新的路由实例,否则所有请求都会使用同一个路由实例。同时还要注意的是不能使用路由懒加载的方式,因为这里我把css单独抽离出来,使用懒加载css会被抽离成一个chunk,在服务端渲染的时候这个chunk并不会被自动引入到html中,而是在加载完异步js的时候通过documentcss的链接挂在到html上,由于是在服务端上,document这个对象是不存在,会导致报错。当然,如果你选择不抽离css的话,路由懒加载是完全可以使用的。
import Vue from "vue";
import VueRouter from "vue-router";
import Foo from "./components/Foo.vue";
import Bar from "./components/Bar.vue";
Vue.use(VueRouter);
export default () => {
  const router = new VueRouter({
    mode: "history",
    routes: [
      {
        path: "/",
        component: Foo,
      },
      {
        path: "/bar",
        component: Bar,
      },
    ],
  });
  return router;
};

  • vuex也是需要使用工厂函数并返回一个vuex对象实例,原因跟上面的vue-router一样。在服务端获取数据的时候,服务端会把数据挂在到window.__INITIAL_STATE__上,在客户端的时候需要将数据进行同步,可以使用replaceState这个方法进行数据同步
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);

export default () => {
  const store = new Vuex.Store({
    state: {
      name: "张三"
    },
    mutations: {
      changeName(state, name) {
        state.name = name;
      },
    },
    actions: {
      changeName({ commit }) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            commit("changeName", "李四");
            resolve();
          }, 1000);
        });
      }
  });
  // 如果浏览器执行的时候,需要将服务器设置的最新状态替换掉客户端的
  if (typeof window !== "undefined" && window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
  }
  return store;
};

  • 创建vue实例也是需要通过工厂函数进行创建。
import Vue from "vue";
import createRouter from "./router.js";
import App from "./App.vue";
import createStore from "./store";
export default () => {
  const router = createRouter();
  const store = createStore();
  const app = new Vue({
    router,
    store,
    render: (h) => h(App),
  });
  return { app, router, store };
};

组件异步获取数据

在组件需要异步获取数据的时候,需要在组件内部实现一个asyncData函数中,该函数接受一个store对象参数和一个router参数对象,并且需要返回一个Promise对象,方便在服务端或者客户端获取数据。这里需要特别注意的是,需要异步获取数据的组件只能进行局部注册,不能全局注册,否则在服务端或者客户端不能调用组件的asyncData,导致不能获取数据,这是因为在匹配路由的时候只能匹配到路由组件,路由组件内部使用到的组件不能匹配出来,只能通过路由组件的components属性获取,然后在通过递归的方式调用每个组件内部的asyncData函数。

<template>
  <div>
    Bar
    {{$store.state.name}}
    <Son />
    <Son1 />
  </div>
</template>

<script>
import Son from "./son";
import Son1 from "./son1";
export default {
  components: {
    Son,
    Son1,
  },
  asyncData(store) {
    return store.dispatch("changeName");
  },
};
</script>

编写客户端入口代码

客户端入口代码主要做的是获取数据和渲染视图。获取数据采用的方式在路由导航之前解析数据。采用这种方式的优点就是不用判断在服务器是否已经获取过数据,就是可以防止二次获取数据。但是缺点就是会有卡顿的现象,建议提供一个数据加载指示器。当路由组件需要重用的时候,这种在路由导航之前解析数据就不太好使了。官网还提供了另外一种获取数据的方式,就是通过全局 mixin ,在beforeMount中调用asyncData函数,但是唯一的缺点就是需要判断是否已经在服务器中获取过数据,以免二次获取数据,造成性能上的浪费。

import createApp from "./main";
const { app, router, store } = createApp();
const getData = (components, store) => {
  const arr = [];
  for (let i = 0; i < components.length; i++) {
    const component = components[i];
    if (component.asyncData) {
      arr.push(component.asyncData(store));
    }
    const sonComp = component.components || {};
    if (Object.keys(sonComp).length !== 0) {
      const sonArr = Object.keys(sonComp).map((son) => sonComp[son]);
      arr.push(...getData(sonArr, store));
    }
  }
  return arr;
};
router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to);
    const prevMatched = router.getMatchedComponents(from);
    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false;
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = prevMatched[i] !== c);
    });
    if (!activated.length) {
      return next();
    }
    const dataList = getData(activated, store);
    // 这里如果有加载指示器 (loading indicator),就触发
    Promise.all(
      dataList
    )
      .then(() => {
        // 停止加载指示器(loading indicator)
        next();
      })
      .catch(next);
  });
  app.$mount("#app");
});

编写服务端入口代码

服务端入口代码跟客户端的入口代码其实是差不多的,唯一不同的是服务端会接受一个context上下文对象,然后根据请求路径匹配出路由组件。同时还要将vuex状态挂载到上下文中

import createApp from "./main";
const getData = (components, store) => {
  const arr = [];
  for (let i = 0; i < components.length; i++) {
    const component = components[i];
    if (component.asyncData) {
      arr.push(component.asyncData(store));
    }
    const sonComp = component.components || {};
    if (Object.keys(sonComp).length !== 0) {
      const sonArr = Object.keys(sonComp).map((son) => sonComp[son]);
      arr.push(...getData(sonArr, store));
    }
  }
  return arr;
};
export default (context) => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp();
    router.push(context.url);

    // 涉及到异步组件
    router.onReady(() => {
      // 获取路径匹配到的组件
      const matches = router.getMatchedComponents();
      if (matches.length === 0) {
        reject({ code: 404 });
        return;
      }
      const dataList = getData(matches, store);
      // 获取异步数据
      Promise.all(
        dataList
      ).then(() => {
        // 将vuex状态挂载到上下文中,会将状态挂载到window上
        context.state = store.state;
        resolve(app);
      });
    }, reject);
  });
};

后台代码

后台主要是使用vue-server-renderer这个插件进行渲染html,这里需要注意的是index.ssr.html这个html需要有 <!--vue-ssr-outlet--> 这个占位符

const experss = require("express");
const VueServerRender = require("vue-server-renderer");
const fs = require("fs");
const app = experss();
const router = experss.Router();
const ServerBundle = require("./dist/vue-ssr-server-bundle.json");
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
const template = fs.readFileSync("./dist/index.ssr.html", "utf-8");
const render = VueServerRender.createBundleRenderer(ServerBundle, {
  template,
  clientManifest,
});
app.use(experss.static("./dist"));
app.use(async (req, res) => {
  try {
    const str = await new Promise((resolve, reject) => {
      render.renderToString({ url: req.url }, (err, data) => {
        if (err) {
          reject(err);
          return;
        }
        resolve(data);
      });
    });
    res.end(str);
  } catch (error) {
    res.end("404");
  }
});
app.listen(3000, function() {
  console.log("localhost:3000");
});

总结

其实ssr主要的难点还是在于怎么去获取数据的问题,怎么防止二次获取数据。而且获取到的数据都是存储在vuex中的,怎么把组件获取到的数据存储到组件本身内部这个暂时我还没想到怎么实现。最后这篇文章的代码已经放在了github上面,有需要的可以拉代码去参考一下,https://github.com/c10342/vue-ssr

 类似资料: