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

2,380 阅读4分钟

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

概念

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

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

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

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

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

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

思路

集成 Router 和 Store

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

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

// 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进行数据处理。

// App.vue
export default {
  /* 其他属性 */
  computed: { ...mapState(["recommend"]) },
  asyncData(store, router, context) {
    /* 触发store更新 */
    return store.dispatch("updateRecommend", context);
  },
  /* 其他属性 */
};

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

// 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 逻辑打包进对应文件中。

// 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__

// 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 层,在请求访问时,挂载在当前请求的上下文中,造成了页面生成与请求上下文强耦合。

// 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 中从而减少消耗。