乐趣区

关于前端:SSR服务化的落地

前言

本文分享了笔者在过往落地 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 服务的治理,缓存解决等问题。

最初

本文写的相对来说比拟简陋,有不明确的中央可随时发表评论探讨。

退出移动版