工程化下的SSR初探-降级渲染

该文章阅读需要 7 分钟,更多文章请点击本人博客halu886

概念

在续上篇 ssr 骨架搭建之后,服务端渲染生成的 HTML 代码直接渲染在浏览器客户端上,可以大大减少 TTC(time-to-content)。

但是在现在前端 MVVM 的框架中,例如 VUE,React,都是在单页面中采用动态虚拟 DOM 的思路进行实现页面的交互和组件的更新。

如果采用服务端渲染的话,节点都是直接基于 HTML 中的代码片段直接生成的。 MVVM 中的 V(视图模型)这一步直接都省略了,以及相关绑定器也没有实例化不会被绑定。

那么对于现代的前端的框架的支持太不友好了。

所以降级渲染这个概念也就诞生了,所谓的降级渲染通俗理解则是一套代码基于 SSR 渲染后,在客户端后降级为 CSR(客户端渲染)

这样就能同时享受到两个渲染方式带来到便利和优势。

思路

集成 Router 和 Store

我们先将代码的路由和数据状态分别托管到 Router 和 Store 组件中,将项目逻辑细化提升可维护性和减少代码量,

并且基于 Router 对事件触发数据的更新。同时只用 Store 对接存在差异的 Api 层,让组件对数据的处理无感知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// store/index.js
import * as api from "../api";

Vue.use(Vuex);

export default () => {
return new Vuex.Store({
state: {
recommend: [],
top: [],
},
mutations: {
updateRecommend(state, recommend) {
/**/
},
updateTop(state, top) {
/**/

},
},
actions: {
async updateTop({ commit }, context) {
// 调用API封装层
let tops = await api.fetchTop(context);
/**/
commit("updateTop", tops);
},
async updateRecommend({ commit }, context) {
// 调用API封装层
let recommends = await api.fetchRecommder(context);
/**/
commit("updateRecommend", recommends);
},
},
});
};

// roter/index.js
import main from "../App.vue";
export default () => {
return new VueRouter({
routes: [
{
path: "/",
component: main,
children: [
{
path: "top",
/* 动态加载组件减少初始化依赖包所需要的大小 */
component: () => import("../components/mainHeader/index.vue"),
},
{
path: "bottom",
/* 动态加载组件减少初始化依赖包所需要的大小 */
component: () => import("../components/mainFooter/index.vue"),
},
],
},
],
});
};

抽象逻辑层

为了减少对于相关服务端或者客户端对于数据拉取的重复代码,我们在每个 Vue 组件中封装通用的方法asyncData进行数据处理。

1
2
3
4
5
6
7
8
9
10
// App.vue
export default {
/* 其他属性 */
computed: { ...mapState(["recommend"]) },
asyncData(store, router, context) {
/* 触发store更新 */
return store.dispatch("updateRecommend", context);
},
/* 其他属性 */
};

在 router 匹配时触发该方法进行数据获取挂载在 Store 上,然后在服务端和客户端路由变化时触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// web/index.js  服务端打包入口文件
export default (context) => {
return new Promise((resolve, reject) => {
const appInit = new Vue({
/* 初始化相关设置(router/store/render) */
});
router.push(context.path); // 通过将上下文的路由手动推入router中
router.onReady(() => {
// 当router准备完毕后进行数据加载
const routeComponents = router.getMatchedComponents();
Promise.all(
routeComponents
.map(({ asyncData }) => {
// 调用每个组件手工对外开发的asyncData接口
asyncData && asyncData(store, router, context);
})
.filter((_) => _)
)
.then(() => {
/* 部分业务处理 */
resolve(appInit);
})
.catch((e) => {
reject(e);
});
});
});
};

我们将所有数据逻辑统一管理在 Store 中,其中也负责统一对 Api 进行调用

由于在服务端渲染相关数据处理封装在 Service 层,客户端相关数据获取通过 HTTP 请求获取,所以这里分别封装service-apiclient-api开放标准接口,在 webpack 打包中使用alias特性将对于 API 逻辑打包进对应文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// client-api
export default function () {
return {
fetchTop: async () => {
return (await axios.get("/get/top")).data;
},
fetchRecommder: async () => {
return (await axios.get("/get/recommender")).data;
},
};
}

// server-api
export default function (ctx) {
return {
fetchTop: ctx.service.header.getTop,
fetchBottom: ctx.service.bottom.getBottom,
};
}

// api
import api from "api"; // 通过分别在Server和Client Webpack打包配置中Alias属性配置对应api文件

export async function fetchTop(context) {
return await api(context).fetchTop(); //服务端渲染时传入当前请求上下文
}

export async function fetchRecommder(context) {
return await api(context).fetchRecommder(); //服务端渲染时传入当前请求上下文
}

客户端激活

使用 Webpack 将源码打包后,在生成 HTML 片段时,以参数传入后,将会以预加载。

同时在 Router 的onReady事件后,使用实例化后对 Vue 对象进行\$mount 挂载。

在服务端渲染出的 DOM 根节点上,自动添加了data-server-rendered=”true”属性,与此同时在客户端激活时,Vue 会识别该属性,进行自上而下顺序的匹配 Visual Dom Tree,当无法匹配时,退出混合模式,重新渲染,并且抛出 warm。生产环境跳过检查,直接渲染

服务端渲染时,会将 Store 属性挂载上 Windows 的__INITIAL_STATE__

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// web/client-index.js
const clientApp = new Vue({
render: (h) => h("div", [h("router-view")]),
/**传递相关属性 (store/router)**/
});

if (window.__INITIAL_STATE__) {
/** 将服务端挂载的相关属性挂载到Store对象上,避免重新加载 **/
store.replaceState(window.__INITIAL_STATE__);
}

// 这里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
router.beforeResolve((to, from, next) => {
const components = router.getMatchedComponents(to);
if (!components.length) {
next();
}

Promise.all(components.map((c) => c && c.asyncData(store, router, {})))
.then(next)
.catch(next);
});
clientApp.$mount("#app");
});

踩坑

在 Egg 中当进行 SSR 渲染时,相关业务数据的 fetch 为了兼容同构,另外封装在 Server-api 层,在 Vue 根据路由生成 HTML 时进行调用。

但是相关业务层又封装在 service 层,在请求访问时,挂载在当前请求的上下文中,造成了页面生成与请求上下文强耦合。

1
2
3
4
5
6
7
8
9
// plugin:egg-view-vue-tuji/lib/vuew.js

/* 将this.ctx(当前请求上下文)传入渲染上下文 */
renderer.renderToString(this.ctx, (err, html) => {
if (err) {
reject(err);
}
resolve(html);
});

总结

基于 Egg 进行 Vue SSR 的降级渲染主要就是以上的思路,这样既保留了 SSR 的优势,同时也能兼顾单页面下 MVVM 框架所带来的优势,同时在业务开发的过程对开发人员也可以是无感知。

拉取数据主要通过 Router 的 onReady 事件触发,每个的组件的数据相关的操作都封装在asyncData 方法中。API 层通过 Webpack 的alias属性区分打包。

在 Egg 中将请求上下文传入打包文件中调用 ctx.Service 方法。

服务端会将 Store 中的状态挂载到客户端 Window 对象上的__INITIAL_STATE__上,可以通过store.replaceState植入客户端中的 Store 中从而减少消耗。

谢谢老板,老板必发大财!💰💰💰💰💰💰