探讨一下To C营销页面服务端渲染的必要性以及其背后的原理
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.
最近无论是在公司还是自己研究的项目,都一直在搞 H5 页面服务端渲染
方面的探索,因此本文来探讨一下服务端渲染的必要性以及其背后的原理。
先来看几个问题
To C 的 H5 为什么适合做 SSR
To C
的营销H5
页面的典型特点是:
- 交互相对简单(尤其是由搭建平台搭建的活动页面)
- 对于页面的首屏一般都有比较高的要求
那么此时作为传统的CSR
渲染方式为什么就不合适了呢?
看了下面一小节,也许你就有答案了
为什么服务端渲染就比客户端渲染快呢?
我们分别来对比一下两者的DOM
渲染过程。
图片来源The Benefits of Server Side Rendering Over Client Side Rendering
客户端渲染
服务端渲染
客户端渲染,需要先得到一个空的 HTML 页面
(这个时候页面已经进入白屏)之后还需要经历:
- 请求并解析
JavaScript
和CSS
- 请求后端服务器获取数据
- 根据数据渲染页面
几个过程才可以看到最后的页面。
特别是在复杂应用中,由于需要加载 JavaScript
脚本,越是复杂的应用,需要加载的 JavaScript
脚本就越多、越大,这就会导致应用的首屏加载时间
非常长,进而影响用户体验感。
相对于客户端渲染,服务端渲染在用户发出一次页面 url
请求之后,应用服务器返回的 html
字符串就是完备的计算好的,可以交给浏览器直接渲染,使得 DOM
的渲染不再受静态资源和 ajax
的限制。
服务端渲染限制有哪些?
但服务端渲染真的就那么好吗?
其实,也不是。
为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况,对第三方库的要求比较高,如果想直接在 Node 渲染过程中调用第三方库,那这个库必须支持服务端渲染。对应的代码复杂度提升了很多。
由于服务器增加了渲染 HTML
的需求,使得原本只需要输出静态资源文件的 nodejs
服务,新增了数据获取的 IO
和渲染 HTML
的 CPU
占用,如果流量陡增,有可能导致服务器宕机,因此需要使用相应的缓存策略和准备相应的服务器负载。
对于构建部署也有了更高的要求,之前的SPA应用
可以直接部署在静态文件服务器上,而服务器渲染应用,需要处于 Node.js server
运行环境。
Vue SSR 原理
聊了这么多可能你对于服务端渲染的原理还不是很清楚,下面我就以Vue
服务端渲染为例来简述一下其原理:
这张图来自Vue SSR 指南
原理解析参考如何搭建一个高可用的服务端渲染工程
Source
为我们的源代码区,即工程代码。
Universal Appliation Code
和我们平时的客户端渲染的代码组织形式完全一致,因为渲染过程是在Node
端,所以没有DOM
和BOM
对象,因此不要在beforeCreate
和created
生命周期钩子里做涉及DOM
和BOM
的操作。
比客户端渲染多出来的app.js
、Server entry
、Client entry
的主要作用为:
app.js
分别给Server entry
、Client entry
暴露出createApp()
方法,使得每个请求进来会生成新的app
实例- 而
Server entry
和Client entry
分别会被webpack
打包成vue-ssr-server-bundle.json
和vue-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
的根结点加上app
的id
:
<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-plugin
和client-plugin
分别生成vue-ssr-server-bundle.json
和vue-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); });
通过使用store
的replaceState
函数,将window.__INITIAL_STATE__
同步到store
内部,完成数据模型的状态同步。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK