Vue 服务端渲染 | 蛋烘糕
蛋烘糕.

不写博客的工程师不是好的搬砖工🧱

Vue 服务端渲染

Cover Image for Vue 服务端渲染
蛋烘糕
蛋烘糕

什么是服务端渲染,简单理解就是将组件或页面通过服务器生成 html 字符串,在发送到浏览器,最后将讲台标记“混合”为客户端上完全交互的应用程序。于传统的 SPA(单页应用)相比,服务端渲染能更好的的有利于 SEO,减少页面首屏加载时间,当然对开发来讲我们就不得不多学一些知识来支持服务端渲染。同事服务端渲染对服务器的压力也是相对比较大的,和服务器简单输出静态文件相比,通过 node 去渲染出页面再传递给客户端显然开销是比较大的,需要注意准备好相应的服务器负载。

一、一个简单的例子

// 第1步:穿件一个vue实例
const Vue = require("vue");
const app = new Vue({
  template: `<div>Hello World</div>`,
});
// 第2步:穿件一个renderer
const renderer = require("vue-server-renderer").createRenderer();
// 第3步:将Vue实例渲染为HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err;
  console.log(html);
  // => <div data-server-renderer="true">Hello World</div>
});

上面例子利用 vue-server-renderer npm 包将一个 vue 示例最后渲染出了一段 html。将这段 html 发送给客户端就轻松的实现了服务器渲染了。

const servr = require("express")();
server.get("*", (req, res) => {
  // ... 生成 html
  res.end(html);
});
server.listen(8080);

二、官方渲染步骤

上面例子虽然简单,但在实际项目中往往需要考虑路由,数据,组件化等等,所以服务端渲染不是只用给一个 vue-server-renderer npm 包就能轻松搞定的,下面给出一张 Vue 官方的服务器渲染示意图:

img

流程图大致意思是:将 Source(源码)通过 webpack 打包出两个 bundle,其中一个 Server Bundle 是给服务端用的,服务端通过渲染器 bundleRenderer 将 bundle 生成 html 给浏览器用;另一个 Client Bundle 是给浏览器用的,别忘了服务端只是生成前期首屏页面所需的 html,后期的交互和数据处理还是需要能支持浏览器脚本的 Client bundle 来完成。

三、具体怎么实现

实现过程就是将上面的示意图转化成代码实现,不过这个过程还是有点小复杂的,需要多点耐心去推敲每个细节。

1、先实现一个基本版

项目结构示例:

├── build
│   ├── webpack.base.config.js     # 基本配置文件
│   ├── webpack.client.config.js   # 客户端配置文件
│   ├── webpack.server.config.js   # 服务端配置文件
└── src
    ├── router
    │    └── index.js              # 路由
    └── views
    │    ├── comp1.vue             # 组件
    │    └── copm2.vue             # 组件
    ├── App.vue                    # 顶级 vue 组件
    ├── app.js                     # app 入口文件
    ├──  client-entry.js           # client 的入口文件
    ├──  index.template.html       # html 模板
    ├──  server-entry.js           # server 的入口文件
├──  server.js                     # server 服务

其中:

(1) comp1.vue 和 copm2.vue 组件
<template>
  <section>组件1</section>
</template>
<script>
  export default {
    data () {
      return {
        msg: ''
      }
    }
  }
</script>
(2) App.vue 顶级 Vue 组件
<template>
  <div id="app">
    <h1>vue-ssr</h1>
    <router-link class="link" to="/comp1">to comp1</router-link>
    <router-link class="link" to="/comp2">to comp2</router-link>
    <router-view class="view"></router-view>
  </div>
</template>

<style lang="stylus">
  .link
      margin 10px
</style>
(3) index.template.html 模板
<!DOCTYPE html>
<html lang="zh_CN">
  <head>
    <title>{{ title }}</title>
    <meta charset="utf-8" />
    <meta name="mobile-web-app-capable" content="yes" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1" />
    <meta name="renderer" content="webkit" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"
    />
    <meta name="theme-color" content="#f60" />
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>
(4) 路由 router
import Vue from "vue";
import Router from "vue-router";
import comp1 from "../views/comp1.vue";
import comp2 from "../views/comp2.vue";
Vue.use(Router);
export function createRouter() {
  return new Router({
    mode: "history",
    scrollBehavior: () => ({ y: 0 }),
    routes: [
      {
        path: "/comp1",
        component: comp1,
      },
      {
        path: "/comp2",
        component: comp2,
      },
      { path: "/", redirect: "/comp1" },
    ],
  });
}

(5) app.js app 入口文件

import Vue from "vue";
import App from "./App.vue";
import { createRouter } from "./router";

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

我们通过 createApp 暴露一个根 Vue 实例,这是为了确保每个用户能得到一份新的示例,避免状态污染,所以我们写了一个可以重复执行的工厂函数 createApp。同样路由 router 我们也是一样的处理方式 createRouter 来暴露一个 router 实例

(6) client-entry.js client 的入口文件
import { createApp } from "./app";

const { app, router } = createApp();
router.onReady(() => {
  app.$mount("#app");
});

客户端代码是在路由解析完成的时候将 app 挂载到 #app 标签下

(7) server-entry.js server 的入口文件
import { createApp } from "./app";

export default (context) => {
  // 因为这边 router.onReady 是异步的,所以我们返回一个 Promis
  // 确保路由或组件准备就绪
  return new Promise((resolve, reject) => {
    const { app, router } = createApp(context);
    router.push(context.url);
    router.onReady(() => {
      resolve(app);
    }, reject);
  });
};

服务器的入口文件我们返回了一个 promise

2、打包

在第一步我们大费周章实现了一个带有路由的日常功能模板代码,接着我们需要利用 webpack 将上面的代码打包出服务端和客户端 key 的代码,入口文件分别是 server-entry.js 和 client-entry.js

(1) webpack 构建配置

一般配置分为是哪个文件:base,client 和 server。基本配置(base config)包含在两个环境共享的配置,例如,输出路径(output path),别名(alias)和 loader。服务器配置(server config)和客户端配置(client config),可以通过使用 webpack-merge 来简单地扩展基本配置。

a. webpack.base.config 配置文件
const path = require("path");
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");

module.exports = {
  devtool: "#cheap-module-source-map",
  output: {
    path: path.resolve(__dirname, "../dist"),
    publicPath: "/",
    filename: "[name]-[chunkhash].js",
  },
  resolve: {
    alias: {
      public: path.resolve(__dirname, "../public"),
      components: path.resolve(__dirname, "../src/components"),
    },
    extensions: [".js", ".vue"],
  },
  module: {
    noParse: /es6-promise\.js$/,
    rules: [
      {
        test: /\.(js|vue)/,
        use: "eslint-loader",
        enforce: "pre",
        exclude: /node_modules/,
      },
      {
        test: /\.vue$/,
        use: {
          loader: "vue-loader",
          options: {
            preserveWhitespace: false,
            postcss: [
              require("autoprefixer")({
                browsers: ["last 3 versions"],
              }),
            ],
          },
        },
      },
      {
        test: /\.js$/,
        use: "babel-loader",
        exclude: /node_modules/,
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        use: {
          loader: "url-loader",
          options: {
            limit: 10000,
            name: "img/[name].[hash:7].[ext]",
          },
        },
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        use: {
          loader: "url-loader",
          options: {
            limit: 10000,
            name: "fonts/[name].[hash:7].[ext]",
          },
        },
      },
      {
        test: /\.css$/,
        use: ["vue-style-loader", "css-loader"],
      },
      {
        test: /\.json/,
        use: "json-loader",
      },
    ],
  },
  performance: {
    maxEntrypointSize: 300000,
    hints: "warning",
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: { warning: false },
    }),
    new ExtractTextPlugin({
      filename: "common.[chunkhash].css",
    }),
  ],
};
b. webpack.client.config.js 配置文件
const path = require("path");
const webpack = require("webpack");
const merge = require("webpack-merge");
const base = require("./webpack.base.config");
const glob = require("glob");
const VueSSRClientPlugin = require("vue-server-renderre/client-plugin");

const config = mrege(base, {
  entry: {
    app: "./src/client-entry.js",
  },
  resolve: {
    alias: {
      "create-api": "./create-api-client.js",
    },
  },
  plugins: [
    new webpack.DefinPlugin({
      "process.env.NODE_ENV": JSON.stringify(
        process.env.NODE_ENV || "development"
      ),
      "process.env.VUE_ENV": '"client"',
      "process.env.DEBUG_API": '"true"',
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: "vendor",
      minChunks: function (module) {
        return (
          /node_modules/.test(module.context) && !/\.css$/.test(module.require)
        );
      },
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
    }),
    // 这是讲服务器的整个输出
    // 构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueSSRClientPlugin(),
  ],
});
module.exports = config;
c. webpack.server.config.js 配置文件
const path = require("path");
const webpack = require("webpack");
const merge = require("webpack-merge");
const base = require("./webpack.base.config");
const nodeExternals = require("webpack-node-externals");
const VueSSRServerPlugin = require("vue-server-renderre/server-plugin");

module.exports = merge(base, {
  target: "node",
  devtool: "#source-map",
  entry: "./src/server-entry.js",
  output: {
    filename: "server-bundle.js",
    libraryTarget: "commonjs2",
  },
  resolve: {
    alias: {
      "create-api": "./create-api-server.js",
    },
  },
  externals: nodeExternals({
    whitelist: /\.css$/,
  }),
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify(
        process.env.NODE_ENV || "development"
      ),
      "process.env.VUE_ENV": '"server"',
    }),
    new VueSSRServerPlugin(),
  ],
});

webpack 配置完成,其实东西不多,都是常规配置。需要注意的是 webpack.server.config.js 配置,output 是生成一个 commmonjs 的 library,VueSSRServerPlugin 用这是将服务器的整个输出构建为单个 JSON 文件的插件。

(2) webpack build poject

build 代码

webpack --config build/webpack.client.config.js
webpack --config build/webpack.server.config.js

打包后会生成一些打包文件,其中 server.config 打包后会生成 vue-ssr-server-bundle.json 文件,这个文件是给 createBundleRender 用的,用于服务端渲染 html 文件

const { createBundleRenderer } = require("vue-server-renderer");
const renderer = createBundleRenderer("/path/to/vue-ssr-server-bundle.json", {
  // ....renderer 的其他选项
});

细心的你还会发现 client.config 不仅生成了一个客户端用到的 js 文件,还会生成一份 vue-ssr-client-manifest.json 文件,这个文件是客户端构建清单,服务端难道这份构建清单会找到用户初始化 js 脚本或 css 注入到 html 一起发送给浏览器。

(3) 服务端渲染

其实上面都是准备工作,最重要的异步是将 webpack 构建后的资源代码给服务端用于生成 html。我们需要用 node 写一个服务端应用,通过打包后的资源生成 html 并发送给浏览器。

server.js

const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const KoaRuoter = require("koa-router");
const serve = require("koa-static");
const { createBundleRenderer } = require("vue-server-renderer");
const LRU = require("lru-cache");

const resolve = (file) => path.resolve(__dirname, file);
const app = new Koa();
const router = new KoaRuoter();
const template = fs.readFileSync(resolve("./src/index.template.html"), "utf-8");

function createRenderer(bundle, options) {
  return createBundleRenderer(
    bundle,
    Object.assign(options, {
      template,
      cache: LRU({
        max: 1000,
        maxAge: 1000 * 60 * 15,
      }),
      basedir: resolve("./dist"),
      runInNewContext: false,
    })
  );
}

let renderer;
const bundle = require("./dist/vue-ssr-server-bundle.json");
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
renderer = createRenderer(bundle, {
  clientManifest,
});

/**
 * 渲染函数
 * @param ctx
 * @param next
 * @returns {Promise}
 */
function render(ctx, next) {
  ctx.set("Content-Type", "text/html");
  return new Promise(function (resolve, reject) {
    const handleError = (err) => {
      if (err && err.code === 404) {
        ctx.status = 404;
        ctx.body = "404 | Page Not Found";
      } else {
        ctx.status = 500;
        ctx.body = "500 | Internal Server Error";
        console.error(`error during render : ${ctx.url}`);
        console.error(err.stack);
      }
      resolve();
    };
    const context = {
      title: "Vue Ssr 2.3",
      url: ctx.url,
    };
    renderer.renderToString(context, (err, html) => {
      if (err) {
        return handleError(err);
      }
      console.log(html);
      ctx.body = html;
      resolve();
    });
  });
}

// app.use(serve("/dist", "./dist", true));
// app.use(serve("/public", "./public", true));
app.use(serve(__dirname + "/dist"));

router.get("*", render);
app.use(router.routes()).use(router.allowedMethods());

const port = process.env.PORT || 8089;
app.listen(port, "0.0.0.0", () => {
  console.log(`server started at localhost:${port}`);
});

这里我们用到了最开始 demo 用到的 vue-server-renderer npm 包,通过读取 vue-ssr-server-bundle.json 和 vue-ssr-client-manifest.json 文件 renderer 出 html,最后 ctx.body = html 发送给浏览器,我们试着 console.log(html) 出 html 看看服务端到底渲染出了何方神圣:

<!DOCTYPE html>
<html lang="zh_CN">
  <head>
    <title>Vue Ssr 2.3</title>
    <meta charset="utf-8" />
    <meta name="mobile-web-app-capable" content="yes" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1" />
    <meta name="renderer" content="webkit" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"
    />
    <meta name="theme-color" content="#f60" />
    <link
      rel="preload"
      href="/dist/manifest-56dda86c1b6ac68c0279.js"
      as="script"
    />
    <link
      rel="preload"
      href="/dist/vendor-3504d51340141c3804a1.js"
      as="script"
    />
    <link rel="preload" href="/dist/app-ae1871b21fa142b507e8.js" as="script" />
    <style data-vue-ssr-id="41a1d6f9:0">
      .link {
        margin: 10px;
      }
    </style>
    <style data-vue-ssr-id="7add03b4:0"></style>
  </head>
  <body>
    <div id="app" data-server-rendered="true">
      <h1>vue-ssr</h1>
      <a href="/comp1" class="link router-link-exact-active router-link-active"
        >to comp1</a
      >
      <a href="/comp2" class="link">to comp2</a>
      <section class="view">组件 1</section>
    </div>

    <script src="/dist/manifest-56dda86c1b6ac68c0279.js" defer></script>
    <script src="/dit/vendor-3504d51340141c3804a1.js" defer></script>
    <script src="/dist/app-ae1871b21fa142b507e8.js" defer></script>
  </body>
</html>

可以看到服务端把路由下的 组件 1 也给渲染出来了,而不是让客户端去动态加载,其次是 html 也被注入了一些 script 标签去加载对应的客户端资源。这里再多说一下,有的同学可能不理解,服务端渲染不就是最后输出 html 让浏览器渲染吗,怎么 html 还带 js 脚本,注意,服务端渲染出的 html 只是首次展示给用户的页面而已,用户后期操作页面处理数据还是需要 js 脚本去跑的,也就是 webpack 为什么要打包出一套服务端代码(用于渲染首次 html 用),一套客户端代码(用于后期交互和数据处理用)

四、小结

本篇简单了解了 vue ssr 的简单流程,服务端渲染还有比较重要的一部分是首屏数据的获取渲染,一般页面展示都会有一些网络数据初始化,服务端渲染可以将这些数据获取到插入到 html。

React16.3+ 新的生命周期钩子
一些常用的正则表达式
博客日历
2022年10月
26
27
28
29
30
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
01
02
03
04
05
06
更多