1

前言

本文分享了笔者在过往落地SSR时的一个不同落地思路,希望通过此文给尝试落地SSR的团队多一个可以尝试的方向。本文以VUE框架的实现为例说明整体落地过程,其它框架可在渲染层进行适配处理。

背景

本人之前所在团队业务主要为做C端为主,SEO相关的项目也在本人所在团队,在做SEO项目的过程中不断演化出SSR的服务化方案,通过本文简要介绍服务化方式的主要思路。

整体的技术方案

先上整体技术方案的设计图
整体方案
在用户访问层通过Nginx解决当SSR出问题或当前页面不需要SSR时进行降级到CSR。
在落地页项目中需要做的改动相对来说较低,首相需要在落地页项目中抽象出store一层,通过store一层解决csr/ssr中的页面初始数据获取/注入的问题。其次在落地页项目中入口文件将主页面组件暴露出去,以便在SSR服务器中获取到当前页面的组件进行渲染。
在SSR服务的实现中以核心 + 插件的形式提供服务。底层核心功能负责处理当前请求的渲染逻辑,返回提供给用户的html内容。插件实现与业务相对来说关系没那么紧密,但是对业务有增强作用的一些能力。绿色部分重的组件为所在团队的一些相关业务插件。

Nginx部分

nginx部分主要实现SSR/CSR的降级处理。如果当前请求需要SSR则进行SSR处理,如果当前请求不需要SSR处理则返回给nginx处理返回给用户CSR相关的实现。
在实现的过程中所在域名的所有前端请求皆会由Nginx反向代理到SSR服务上,SSR服务不需要处理或者处理出问题时皆抛出异常返回状态码给Nginx,在nginx上通过error_page配置将请求降级为CSR处理。配置大概如下:

proxy_intercept_errors  on;
error_page      400 404 500 502 = @local;

其中@local为处理返回CSR静态资源的配置。

落地页项目相关处理

在SSR服务化的方案中由于SSR服务需要抓取落地页项目的html及js内容进行渲染处理,为了方便SSR服务抓取处理js,在落地页项目中对打包的js资源进行了3方面的处理:

  1. 将js资源打包为library,一般格式为umd,方便抓取后进行处理。
  2. 入口js进行CSR/SSR兼容处理。在SSR页面中会注入$$SSR的环境变量,如果页面为CSR则进行mount处理,由于SSR没有mount操作,所以无需进行mount处理。将入口的js对象暴露出去,方便SSR服务抓取js对象进行渲染处理。
  3. 抽象出store一层,进行页面初始化数据的获取,在store一层进行CSR/SSR的兼容处理,让开发人员在开发的时候只需要按照规范处理页面初始数据请求即可。

其中1与其他js library打包没有区别,此处不做说明。
2的处理较为简单,大致如下:

function render() { // 渲染函数,方便服务端抓取进行渲染处理。
  return new Vue({
    router,
    render: h => h(App)
  });
}

if (!window.$$ssr) { // SSR服务会在页面注入$$SSR环境变量,方便做处理
  render().$mount('#app')
}

export default {
  render, // 服务端抓取该函数进行渲染处理
  el: '#app', // 服务端抓取该配置将渲染后的dom string添加到该节点下
  router, // 暴露路由方便服务端mock用户端的请求。
  store: { // 此处为3抽象的store层,在本例中方便处理没有将store单独抽出一个文件处理。
    '/home': store,
  }
}

其中3抽象的store一层本例中基于vue进行抽象,主要功能为处理CSR/SSR的兼容。store的核心代码如下:

import Vue from 'vue';

export default function createStore(store) {
    const _vm = new Vue({ data: { state: store.state } });
    store.state = _vm.$data.state;

    return {
        data() {
            return {
                store
            }
        },

        computed: {
            $store() {
                return this.store.state
            }
        },

       async beforeCreate () { // 由于vue的SSR只执行beforeCreate和created2个生命周期,本例中在beforeCreated这个节点进行页面初始化数据的抓取和处理。最后返回state主要为方便服务端抓取数据进行处理。
            const state = window.__INITIAL_STATE__ || await store.init();
            this.$set && this.$set(store, 'state', state);
            return state;
        }
    }
}

上面代码最为createStore的方法,实现较为简单,可以根据团队的具体情况进行封装,在具体的业务页面中store层通过调用该方法创建store,页面中使用到的初始数据也从store层进行抓取处理,页面中的store层代码大致如下:

import createStore from '../store';
import axios from 'axios';

export default createStore({
    state: { name: 'csr' },
    async init() {
        const { data } = await axios.get('/api/test.json');
        return data;
    }
})

页面使用store的代码大致如下:

<template>
  <div @click="onClickHandler">
    {{ store.state.name }}
  </div>
</template>
<script>
import store from './store';

export default {
  mixins: [store],

  methods: {
    onClickHandler() {
      alert('click')
    }
  }
}
</script>

至此C端项目的相关改造已经完成,在启动时访问页面对应的路由即可看见页面初始状态相关的信息。

SSR服务端方案

在服务端插件系统主要是基于koa的洋葱模型实现,此处不做过多说明,可参照相关的koa或者egg js等文章查看详情。服务端主要实现为下半部分的沙箱环境处理,由于js是从落地页进行抓取的,无法确保js代码的安全性,并且需要在服务端模拟用户访问的环境及相关的操作,所以相关渲染部分的代码都运行在js沙箱中。执行渲染的过程主要分为2部分:

  1. 模拟用户访问的环境
  2. 资源抓取和渲染

其中第一部分模拟用户访问的环境主要基于jsdom实现,大致代码如下:

import { JSDOM, CookieJar, ResourceLoader } from 'jsdom';

// 基于Egg JS的示例代码
export default function createMockObject(html) {
    const { ctx } = this;
    const cookieJar = new CookieJar();
    (ctx.request.header.cookie as string || '').split(';').forEach(cookie => {
        if (cookie) cookieJar.setCookie(cookie, `${ctx.request.protocol}://${ctx.request.host}/`, {}, noop)
    })
    const dom = new JSDOM(html, {
        url: ctx.request.href,
        referrer: ctx.referrer,
        contentType: "text/html",
        cookieJar,
        resources: new ResourceLoader({
        userAgent: ctx.get('user-agent')
        })
    });
    const window = dom.window;
    const exports = {};
    const module = { exports };

    window.$$ssr = true;

    return {
        window,
        ...window,
        XMLHttpRequest: window.XMLHttpRequest,
        exports,
        module
    };
}

在模拟用户行为的过程中当前主要使用到的环境会是用户的cookie信息,ua信息以及接口请求的xhr对象等。

第二部分的资源抓取和渲染主要分为抓取和渲染2部分,资源抓取使用http请求进行抓取。html资源的话与当前处理的请求一致,在此处抓取资源时需注意处理,如果域名也一致的话在抓取时可在连接上添加参数处理,以免SSR服务陷入请求的死循环中。js资源的话可以以配置文件的形式配置请求的url地址对应的抓取资源地址,之后基于http请求进行js资源的抓取。数据抓取部分主要由服务端在抓取到js资源后根据当前请求的path获取对应的store层,通过store层的方法调用 + mock的xhr对象进行数据抓取。资源抓取之后即可以使用vue的ssr renderer组件进行渲染并拼接dom string返回给用户。
在此过程中抓取html和js资源的过程较为简单,此处暂不做说明,主要问题为抓取到js后的处理和数据抓取,其中抓取到js后的处理过程主要如下:

const script: vm.Script = new vm.Script(esModuleJS);
const ctx = vm.createContext(obj);
script.runInNewContext(ctx);
return (ctx as any).module.exports.default;
const script = await ctx.service.sandbox.run(js, dom); // js 为抓取到的js文件代码,dom为上面createMockObject函数返回的mock对象。此处实现代码在上面代码块
ctx.body = await ctx.service.render.render(script, dom);


// 下面代码为render service中的方法,本文中以egg js为例实现。
public getDocType(docType: DocumentType) {
    return '<!DOCTYPE '
            + docType.name
            + (docType.publicId ? ' PUBLIC "' + docType.publicId + '"' : '')
            + (!docType.publicId && docType.systemId ? ' SYSTEM' : '') 
            + (docType.systemId ? ' "' + docType.systemId + '"' : '')
            + '>';
}

public async render(js, mock) {
    const { document, window } = mock;
    const data = await this.fetchData(js); // 模拟用户请求,进行页面初始数据的抓取
    window.__INITIAL_STATE__ = data;
    const vnode = await this.getVnode(js);
    const appDom = document.querySelector(js.el);
    const fragment = document.createElement('div');
    fragment.innerHTML = await renderer.renderToString(vnode);

    appDom.parentNode.insertBefore(fragment.childNodes[0], appDom);
    appDom.remove();

    const script = document.createElement('script'); // SSR数据注入,在页面激活时绕过请求直接使用SSR返回的数据
    script.type = 'text/javascript';
    script.text = `window.__INITIAL_STATE__ = ${JSON.stringify(data)}`;
    document.head.appendChild(script);

    return `${this.getDocType(document.doctype)}${document.documentElement.outerHTML}`;
}

private async fetchData(js) { // 数据抓取
    const store = js.store[this.ctx.request.path];
    if (!store) return undefined;
    return await store.beforeCreate();
}

private async getVnode(js) {
    return new Promise((resolve, reject) => {
        const { render, router } = js;
        const app = render();

        router.onReady(() => {
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!router.getMatchedComponents().length) {
                return reject({ code: 404 });
            }
                resolve(app);
        }, reject)
    });
}

在抓取到js之后主要将js代码在当前用户的mock环境沙箱中执行,获取到对应的js对象,并获取对象中的相关方法及变量进行处理,获取到当前页面的相关数据及vnode对象,由vue-server-renderer组件将vnode对象转为dom string并append到当前页面中,至此SSR的服务化大致思路已经实现,在该方案中可实现SSR服务的上下线对用户无感知,并且开发人员只增加对store层的简单理解即可支持开发的页面进行SSR处理,基本可以做到对开发人员无感知。其中还有很多问题需要继续处理,比如SSR服务的治理,缓存处理等问题。

最后

本文写的相对来说比较简陋,有不明白的地方可随时发表评论讨论。


周兴钢
3 声望0 粉丝