10

探讨一下To C营销页面服务端渲染的必要性以及其背后的原理

 2 years ago
source link: https://segmentfault.com/a/1190000041376841
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

最近无论是在公司还是自己研究的项目,都一直在搞 H5 页面服务端渲染方面的探索,因此本文来探讨一下服务端渲染的必要性以及其背后的原理。

先来看几个问题

To C 的 H5 为什么适合做 SSR

To C营销H5页面的典型特点是:

  • 交互相对简单(尤其是由搭建平台搭建的活动页面)
  • 对于页面的首屏一般都有比较高的要求

那么此时作为传统的CSR渲染方式为什么就不合适了呢?

看了下面一小节,也许你就有答案了

为什么服务端渲染就比客户端渲染快呢?

我们分别来对比一下两者的DOM渲染过程。

图片来源The Benefits of Server Side Rendering Over Client Side Rendering

客户端渲染

服务端渲染

客户端渲染,需要先得到一个空的 HTML 页面(这个时候页面已经进入白屏)之后还需要经历:

  • 请求并解析JavaScriptCSS
  • 请求后端服务器获取数据
  • 根据数据渲染页面

几个过程才可以看到最后的页面。

特别是在复杂应用中,由于需要加载 JavaScript 脚本,越是复杂的应用,需要加载的 JavaScript 脚本就越多、越大,这就会导致应用的首屏加载时间非常长,进而影响用户体验感。

相对于客户端渲染,服务端渲染在用户发出一次页面 url 请求之后,应用服务器返回的 html 字符串就是完备的计算好的,可以交给浏览器直接渲染,使得 DOM 的渲染不再受静态资源和 ajax 的限制。

服务端渲染限制有哪些?

但服务端渲染真的就那么好吗?

其实,也不是。

为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况,对第三方库的要求比较高,如果想直接在 Node 渲染过程中调用第三方库,那这个库必须支持服务端渲染。对应的代码复杂度提升了很多。

由于服务器增加了渲染 HTML 的需求,使得原本只需要输出静态资源文件的 nodejs 服务,新增了数据获取的 IO 和渲染 HTMLCPU 占用,如果流量陡增,有可能导致服务器宕机,因此需要使用相应的缓存策略和准备相应的服务器负载。

对于构建部署也有了更高的要求,之前的SPA应用可以直接部署在静态文件服务器上,而服务器渲染应用,需要处于 Node.js server 运行环境。

Vue SSR 原理

聊了这么多可能你对于服务端渲染的原理还不是很清楚,下面我就以Vue服务端渲染为例来简述一下其原理:

这张图来自Vue SSR 指南

原理解析参考如何搭建一个高可用的服务端渲染工程

Source为我们的源代码区,即工程代码。

Universal Appliation Code和我们平时的客户端渲染的代码组织形式完全一致,因为渲染过程是在Node端,所以没有DOMBOM对象,因此不要在beforeCreatecreated生命周期钩子里做涉及DOMBOM的操作。

比客户端渲染多出来的app.jsServer entryClient entry的主要作用为:

  • app.js分别给Server entryClient entry暴露出createApp()方法,使得每个请求进来会生成新的app实例
  • Server entryClient entry分别会被webpack打包成vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json

Node端会根据webpack打包好的vue-ssr-server-bundle.json,通过调用createBundleRenderer生成renderer实例,再通过调用renderer.renderToString生成完备的html字符串

Node端将render好的html字符串返回给Browser,同时Node端根据vue-ssr-client-manifest.json生成的js会和html字符串hydrate,完成客户端激活html,使得页面可交互。

写一个 demo 来落地 SSR

我们知道市面上实现服务端渲染一般有这几种方法:

  • 使用next.js/nuxt.js的服务端渲染方案
  • 使用node+vue-server-renderer实现vue项目的服务端渲染(也就是上面提到的)
  • 使用node+React renderToStaticMarkup/renderToString实现react项目的服务端渲染
  • 使用模板引擎来实现ssr(比如ejs, jade, pug等)

最近要改造的项目正好是 Vue 开发的,目前也考虑基于vue-server-renderer将其改造为服务端渲染的。基于上面分析的原理,我从零一步步搭建了一个最小化的vue-ssr,大家有需要的可直接拿去用~

这里我贴几点需要注意的:

使用 SSR 不存在单例模式

我们知道Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。所以每次用户请求都会创建一个新的 Vue 实例,这也是为了避免交叉请求状态污染的发生。

因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:

// main.js
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
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 };
};

服务端代码构建

服务端代码与客户端代码构建的区别在于:

  • 不需要编译CSS,服务器端渲染会自动将CSS内置
  • 构建目标为nodejs环境
  • 不需要代码切割,nodejs 将所有代码一次性加载到内存中更有利于运行效率
// vue.config.js
// 两个插件分别负责打包客户端和服务端
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const nodeExternals = require("webpack-node-externals");
const merge = require("lodash.merge");
// 根据传⼊环境变量决定⼊⼝⽂件和相应配置项
const TARGET_NODE = process.env.WEBPACK_TARGET === "node";
const target = TARGET_NODE ? "server" : "client";
module.exports = {
  css: {
    extract: false,
  },
  outputDir: "./dist/" + target,
  configureWebpack: () => ({
    // 将 entry 指向应⽤程序的 server / client ⽂件
    entry: `./src/${target}-entry.js`,
    // 对 bundle renderer 提供 source map ⽀持
    devtool: "source-map",
    // target设置为node使webpack以Node适⽤的⽅式处理动态导⼊,
    // 并且还会在编译Vue组件时告知`vue-loader`输出⾯向服务器代码。
    target: TARGET_NODE ? "node" : "web",
    // 是否模拟node全局变量
    node: TARGET_NODE ? undefined : false,
    output: {
      // 此处使⽤Node⻛格导出模块
      libraryTarget: TARGET_NODE ? "commonjs2" : undefined,
    },
    externals: TARGET_NODE
      ? nodeExternals({
          allowlist: [/\.css$/],
        })
      : undefined,
    optimization: {
      splitChunks: undefined,
    },
    // 这是将服务器的整个输出构建为单个 JSON ⽂件的插件。
    // 服务端默认⽂件名为 `vue-ssr-server-bundle.json`
    // 客户端默认⽂件名为 `vue-ssr-client-manifest.json`。
    plugins: [
      TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin(),
    ],
  }),
  chainWebpack: (config) => {
    // cli4项⽬添加
    if (TARGET_NODE) {
      config.optimization.delete("splitChunks");
    }

    config.module
      .rule("vue")
      .use("vue-loader")
      .tap((options) => {
        merge(options, {
          optimizeSSR: false,
        });
      });
  },
};

处理 CSS

正常服务端路由我们可能会这样写:

router.get("/", async (ctx) => {
  ctx.body = await render.renderToString();
});

但这样打包后,启动server你会发现样式没生效。这个问题我们需要通过promise的方式来解决:

pp.use(async (ctx) => {
  try {
    ctx.body = await new Promise((resolve, reject) => {
      render.renderToString({ url: ctx.url }, (err, data) => {
        console.log("data", data);
        if (err) reject(err);
        resolve(data);
      });
    });
  } catch (error) {
    ctx.body = "404";
  }
});

之所以事件没有生效是因为我们没有进行客户端激活操作,也就是把客户端打包出来的clientBundle.js挂载到HTML上。

首先我们要在App.vue的根结点加上appid

<template>
  <!-- 客户端激活 -->
  <div id="app">
    <router-link to="/">foo</router-link>
    <router-link to="/bar">bar</router-link>
    <router-view></router-view>
  </div>
</template>

<script>
import Bar from "./components/Bar.vue";
import Foo from "./components/Foo.vue";
export default {
  components: {
    Bar,
    Foo,
  },
};
</script>

然后通过vue-server-renderer中的server-pluginclient-plugin分别生成vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json文件,也就是服务端映射和客户端映射。

最后在node服务这里做下关联:

const ServerBundle = require("./dist/server/vue-ssr-server-bundle.json");

const template = fs.readFileSync("./public/index.html", "utf8");
const clientManifest = require("./dist/client/vue-ssr-client-manifest.json");
const render = VueServerRender.createBundleRenderer(ServerBundle, {
  runInNewContext: false, // 推荐
  template,
  clientManifest,
});

这样就完成了客户端激活操作,也就支持了 css 和事件。

数据模型的共享与状态同步

在服务端渲染生成 html 前,我们需要预先获取并解析依赖的数据。同时,在客户端挂载(mounted)之前,需要获取和服务端完全一致的数据,否则客户端会因为数据不一致导致混入失败。

为了解决这个问题,预获取的数据要存储在状态管理器(store)中,以保证数据一致性。

首先是创建store实例,同时供客户端和服务端使用:

// src/store.js
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default () => {
  const store = new Vuex.Store({
    state: {
      name: "",
    },
    mutations: {
      changeName(state) {
        state.name = "cosen";
      },
    },
    actions: {
      changeName({ commit }) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            commit("changeName");
            resolve();
          }, 1000);
        });
      },
    },
  });

  return store;
};

createStore加入到createApp中,并将store注入到vue实例中,让所有Vue组件可以获取到store实例:

import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
+ 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 };
};

在页面中使用store

// src/components/Foo.vue
<template>
  <div>
    Foo
    <button @click="clickMe">点击</button>
    {{ this.$store.state.name }}
  </div>
</template>
<script>
export default {
  mounted() {
    this.$store.dispatch("changeName");
  },
  asyncData({ store, route }) {
    return store.dispatch("changeName");
  },
  methods: {
    clickMe() {
      alert("测试点击");
    },
  },
};
</script>

如果用过nuxt的同学肯定知道在nuxt中有一个钩子叫asyncData,我们可以在这个钩子发起一些请求,而且这些请求是在服务端发出的。

那我们来看下如何实现 asyncData 吧,在 server-entry.js 中我们通过 const matchs = router.getMatchedComponents()获取到匹配当前路由的所有组件,也就是我们可以拿到所有组件的 asyncData 方法:

// src/server-entry.js
// 服务端渲染只需将渲染的实例导出即可
import createApp from "./main";
export default (context) => {
  const { url } = context;
  return new Promise((resolve, reject) => {
    console.log("url", url);
    // if (url.endsWith(".js")) {
    //   resolve(app);
    //   return;
    // }
    const { app, router, store } = createApp();
    router.push(url);
    router.onReady(() => {
      const matchComponents = router.getMatchedComponents();
      console.log("matchComponents", matchComponents);
      if (!matchComponents.length) {
        reject({ code: 404 });
      }
      // resolve(app);

      Promise.all(
        matchComponents.map((component) => {
          if (component.asyncData) {
            return component.asyncData({
              store,
              route: router.currentRoute,
            });
          }
        })
      )
        .then(() => {
          // Promise.all中方法会改变store中的state
          // 把vuex的状态挂载到上下文中
          context.state = store.state;
          resolve(app);
        })
        .catch(reject);
    }, reject);
  });
};

通过 Promise.all 我们就可以让所有匹配到的组件中的asyncData执行,然后修改服务端的store了。而且也将服务端的最新store同步到客户端的store中。

客户端激活状态数据

上一步将state存入context后,在服务端渲染HTML时,也就是渲染template的时候,context.state会被序列化到window.__INITIAL_STATE__中:

可以看到,状态已经被序列化到 window.__INITIAL_STATE__中,我们需要做的就是将这个 window.__INITIAL_STATE__在客户端渲染之前,同步到客户端的 store 中,下面修改 client-entry.js

// 客户端渲染手动挂载到 dom 元素上
import createApp from "./main";
const { app, router, store } = createApp();

// 浏览器执行时需要将服务端的最新store状态替换掉客户端的store
if (window.__INITIAL_STATE__) {
  // 激活状态数据
  store.replaceState(window.__INITIAL_STATE__);
}

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

通过使用storereplaceState函数,将window.__INITIAL_STATE__同步到store内部,完成数据模型的状态同步。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK