探討一下To C營銷頁面服務端渲染的必要性以及其背後的原理

前端森林發表於2022-02-09

最近無論是在公司還是自己研究的專案,都一直在搞 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內部,完成資料模型的狀態同步。

相關文章